369 lines
14 KiB
Java
369 lines
14 KiB
Java
package de.opcua.app.scripting;
|
|
|
|
import de.opcua.app.opc.OpcUaService;
|
|
import org.graalvm.polyglot.Context;
|
|
import org.graalvm.polyglot.HostAccess;
|
|
import org.graalvm.polyglot.Value;
|
|
|
|
import java.net.URI;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.time.Duration;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.CompletableFuture;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
/**
|
|
* GraalVM-based JavaScript engine.
|
|
*
|
|
* Important: GraalVM Context is not thread-safe. Therefore every event/script
|
|
* execution receives its own isolated Context. This allows parallel execution
|
|
* where every OPC-UA event/action can run in its own thread without overwriting
|
|
* global JavaScript variables from another event.
|
|
*/
|
|
public class GraalScriptEngine {
|
|
|
|
private final OpcUaService opcService;
|
|
private final Store store;
|
|
private final RestClient restClient;
|
|
private final Map<String, Object> globalBindings;
|
|
private volatile boolean availabilityChecked = false;
|
|
private volatile boolean available = false;
|
|
|
|
public GraalScriptEngine(OpcUaService opcService, Store store) {
|
|
this.opcService = opcService;
|
|
this.store = store;
|
|
this.restClient = new RestClient();
|
|
this.globalBindings = new ConcurrentHashMap<>();
|
|
}
|
|
|
|
private Context createContext() {
|
|
Context context = Context.newBuilder("js")
|
|
.allowHostAccess(HostAccess.ALL)
|
|
.allowIO(true)
|
|
.option("js.ecmascript-version", "2022")
|
|
.build();
|
|
initializeContext(context);
|
|
return context;
|
|
}
|
|
|
|
private void initializeContext(Context context) {
|
|
var bindings = context.getBindings("js");
|
|
|
|
bindings.putMember("opc", new OpcUaFacade(opcService));
|
|
bindings.putMember("rest", restClient);
|
|
bindings.putMember("store", store);
|
|
bindings.putMember("console", new Console());
|
|
bindings.putMember("sleep", new SleepFunction());
|
|
|
|
for (Map.Entry<String, Object> entry : globalBindings.entrySet()) {
|
|
bindings.putMember(entry.getKey(), entry.getValue());
|
|
}
|
|
}
|
|
|
|
public boolean isAvailable() {
|
|
if (availabilityChecked) return available;
|
|
synchronized (this) {
|
|
if (availabilityChecked) return available;
|
|
try (Context ignored = createContext()) {
|
|
available = true;
|
|
System.out.println("[GraalVM] JavaScript engine ready");
|
|
} catch (Exception e) {
|
|
available = false;
|
|
System.err.println("[GraalVM] Failed to initialize: " + e.getMessage());
|
|
System.err.println("[GraalVM] Make sure GraalVM JS dependencies are on the classpath.");
|
|
} finally {
|
|
availabilityChecked = true;
|
|
}
|
|
return available;
|
|
}
|
|
}
|
|
|
|
/** Execute JavaScript code without extra per-event bindings. */
|
|
public Object execute(String script) {
|
|
return execute(script, Map.of());
|
|
}
|
|
|
|
/** Execute JavaScript code with event-specific bindings. */
|
|
public Object execute(String script, Map<String, Object> bindings) {
|
|
if (!isAvailable()) {
|
|
throw new ScriptExecutionException(
|
|
"GraalVM JavaScript engine not available. Make sure graalvm-js dependencies are on classpath.",
|
|
null
|
|
);
|
|
}
|
|
|
|
try (Context context = createContext()) {
|
|
putBindings(context, bindings);
|
|
Value result = context.eval("js", script == null ? "" : script);
|
|
return convertValueToJava(result);
|
|
} catch (Exception e) {
|
|
throw new ScriptExecutionException("Script execution failed", e);
|
|
}
|
|
}
|
|
|
|
/** Evaluate a JavaScript condition and convert the result to boolean semantics. */
|
|
public boolean executeCondition(String conditionScript, Map<String, Object> bindings) {
|
|
Object result = execute(conditionScript, bindings);
|
|
return toBoolean(result);
|
|
}
|
|
|
|
/** Execute script asynchronously. */
|
|
public CompletableFuture<Object> executeAsync(String script) {
|
|
return CompletableFuture.supplyAsync(() -> execute(script));
|
|
}
|
|
|
|
public CompletableFuture<Object> executeAsync(String script, Map<String, Object> bindings) {
|
|
return CompletableFuture.supplyAsync(() -> execute(script, bindings));
|
|
}
|
|
|
|
/** Bind a Java object as a global default for all future event contexts. */
|
|
public void bind(String name, Object value) {
|
|
if (name == null || name.isBlank()) return;
|
|
globalBindings.put(name, value);
|
|
}
|
|
|
|
/** Get a global binding. Event-local bindings are intentionally not retained. */
|
|
public Object get(String name) {
|
|
return globalBindings.get(name);
|
|
}
|
|
|
|
private void putBindings(Context context, Map<String, Object> bindings) {
|
|
if (bindings == null) return;
|
|
var jsBindings = context.getBindings("js");
|
|
for (Map.Entry<String, Object> entry : bindings.entrySet()) {
|
|
String key = entry.getKey();
|
|
if (key == null || key.isBlank()) continue;
|
|
jsBindings.putMember(key, entry.getValue());
|
|
}
|
|
}
|
|
|
|
private Object convertValueToJava(Value value) {
|
|
if (value == null || value.isNull()) return null;
|
|
if (value.isBoolean()) return value.asBoolean();
|
|
if (value.isNumber()) {
|
|
if (value.fitsInInt()) return value.asInt();
|
|
if (value.fitsInLong()) return value.asLong();
|
|
return value.asDouble();
|
|
}
|
|
if (value.isString()) return value.asString();
|
|
if (value.hasArrayElements()) {
|
|
long size = value.getArraySize();
|
|
Object[] array = new Object[(int) size];
|
|
for (int i = 0; i < size; i++) {
|
|
array[i] = convertValueToJava(value.getArrayElement(i));
|
|
}
|
|
return array;
|
|
}
|
|
if (value.isHostObject()) return value.asHostObject();
|
|
return value.toString();
|
|
}
|
|
|
|
private boolean toBoolean(Object result) {
|
|
if (result == null) return false;
|
|
if (result instanceof Boolean b) return b;
|
|
if (result instanceof Number n) return n.doubleValue() != 0.0d;
|
|
String s = result.toString().trim();
|
|
if (s.isEmpty()) return false;
|
|
if ("true".equalsIgnoreCase(s)) return true;
|
|
if ("false".equalsIgnoreCase(s)) return false;
|
|
try {
|
|
return Double.parseDouble(s) != 0.0d;
|
|
} catch (NumberFormatException ignored) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public void close() {
|
|
globalBindings.clear();
|
|
}
|
|
|
|
public static class OpcUaFacade {
|
|
private final OpcUaService opcService;
|
|
|
|
public OpcUaFacade(OpcUaService opcService) {
|
|
this.opcService = opcService;
|
|
}
|
|
|
|
public String read(String nodeId) {
|
|
try {
|
|
return opcService.readValue(nodeId).get();
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("Failed to read node: " + nodeId, e);
|
|
}
|
|
}
|
|
|
|
public boolean write(String nodeId, Object value) {
|
|
try {
|
|
return opcService.writeValue(nodeId, value).get();
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("Failed to write node: " + nodeId, e);
|
|
}
|
|
}
|
|
|
|
public Map<String, String> readMultiple(String[] nodeIds) {
|
|
Map<String, String> results = new HashMap<>();
|
|
if (nodeIds == null) return results;
|
|
for (String nodeId : nodeIds) {
|
|
try {
|
|
results.put(nodeId, read(nodeId));
|
|
} catch (Exception e) {
|
|
results.put(nodeId, "<error: " + e.getMessage() + ">");
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public Map<String, String> readMultiple(List<String> nodeIds) {
|
|
Map<String, String> results = new HashMap<>();
|
|
if (nodeIds == null) return results;
|
|
for (String nodeId : nodeIds) {
|
|
try {
|
|
results.put(nodeId, read(nodeId));
|
|
} catch (Exception e) {
|
|
results.put(nodeId, "<error: " + e.getMessage() + ">");
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
public boolean writeMultiple(Map<String, Object> valuesByNodeId) {
|
|
if (valuesByNodeId == null) return true;
|
|
boolean ok = true;
|
|
for (Map.Entry<String, Object> entry : valuesByNodeId.entrySet()) {
|
|
ok &= write(entry.getKey(), entry.getValue());
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
public boolean isConnected() {
|
|
return opcService.isConnected();
|
|
}
|
|
}
|
|
|
|
public static class RestClient {
|
|
private final HttpClient httpClient;
|
|
|
|
public RestClient() {
|
|
this.httpClient = HttpClient.newBuilder()
|
|
.connectTimeout(Duration.ofSeconds(10))
|
|
.build();
|
|
}
|
|
|
|
public RestResponse get(String url) { return get(url, null); }
|
|
|
|
public RestResponse get(String url, Map<String, String> headers) {
|
|
try {
|
|
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).GET();
|
|
if (headers != null) headers.forEach(builder::header);
|
|
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
|
return new RestResponse(response.statusCode(), response.body(), response.headers().map());
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("GET request failed: " + url, e);
|
|
}
|
|
}
|
|
|
|
public RestResponse post(String url, String body) { return post(url, body, null); }
|
|
|
|
public RestResponse post(String url, String body, Map<String, String> headers) {
|
|
try {
|
|
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
|
|
.header("Content-Type", "application/json");
|
|
if (headers != null) headers.forEach(builder::header);
|
|
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
|
return new RestResponse(response.statusCode(), response.body(), response.headers().map());
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("POST request failed: " + url, e);
|
|
}
|
|
}
|
|
|
|
public RestResponse put(String url, String body) { return put(url, body, null); }
|
|
|
|
public RestResponse put(String url, String body, Map<String, String> headers) {
|
|
try {
|
|
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
|
.uri(URI.create(url))
|
|
.PUT(HttpRequest.BodyPublishers.ofString(body == null ? "" : body))
|
|
.header("Content-Type", "application/json");
|
|
if (headers != null) headers.forEach(builder::header);
|
|
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
|
return new RestResponse(response.statusCode(), response.body(), response.headers().map());
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("PUT request failed: " + url, e);
|
|
}
|
|
}
|
|
|
|
public RestResponse delete(String url) { return delete(url, null); }
|
|
|
|
public RestResponse delete(String url, Map<String, String> headers) {
|
|
try {
|
|
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE();
|
|
if (headers != null) headers.forEach(builder::header);
|
|
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
|
return new RestResponse(response.statusCode(), response.body(), response.headers().map());
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("DELETE request failed: " + url, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static class RestResponse {
|
|
private final int status;
|
|
private final String body;
|
|
private final Map<String, java.util.List<String>> headers;
|
|
|
|
public RestResponse(int status, String body, Map<String, java.util.List<String>> headers) {
|
|
this.status = status;
|
|
this.body = body;
|
|
this.headers = headers;
|
|
}
|
|
|
|
public int getStatus() { return status; }
|
|
public String getBody() { return body; }
|
|
public Map<String, java.util.List<String>> getHeaders() { return headers; }
|
|
public String json() { return body; }
|
|
public boolean isOk() { return status >= 200 && status < 300; }
|
|
}
|
|
|
|
public static class Console {
|
|
public void log(Object... messages) {
|
|
StringBuilder sb = new StringBuilder("[JS] ");
|
|
for (Object msg : messages) sb.append(msg).append(" ");
|
|
System.out.println(sb.toString().trim());
|
|
}
|
|
|
|
public void error(Object... messages) {
|
|
StringBuilder sb = new StringBuilder("[JS ERROR] ");
|
|
for (Object msg : messages) sb.append(msg).append(" ");
|
|
System.err.println(sb.toString().trim());
|
|
}
|
|
|
|
public void warn(Object... messages) {
|
|
StringBuilder sb = new StringBuilder("[JS WARN] ");
|
|
for (Object msg : messages) sb.append(msg).append(" ");
|
|
System.out.println(sb.toString().trim());
|
|
}
|
|
}
|
|
|
|
public static class SleepFunction {
|
|
public void sleep(long milliseconds) {
|
|
try {
|
|
Thread.sleep(milliseconds);
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
}
|
|
}
|
|
|
|
public static class ScriptExecutionException extends RuntimeException {
|
|
public ScriptExecutionException(String message, Throwable cause) {
|
|
super(message, cause);
|
|
}
|
|
}
|
|
}
|