diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..5801f2e --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,20 @@ +name: Build and test +on: [pull_request, workflow_call] + +jobs: + tests: + runs-on: ${{matrix.os}} + strategy: + matrix: + os: [ "ubuntu-latest", "windows-latest"] + version: [21, 25] + fail-fast: false + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v4 + with: + java-version: ${{matrix.version}} + distribution: 'temurin' + architecture: x64 + - name: Build with maven + run: mvn clean install diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f187c67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +.classpath +.project +.settings +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e1ae646 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:17-jdk AS builder +WORKDIR /build +RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/* +COPY . . +RUN mvn --batch-mode --update-snapshots clean package -DskipTests + +FROM eclipse-temurin:17-jre AS runner +WORKDIR /app +COPY --from=builder /build/target/pvws*.jar ./pvws.jar +CMD ["java", "-jar", "/app/pvws.jar", "--spring.config.name=application"] diff --git a/README.md b/README.md index 5889798..9dc9261 100644 --- a/README.md +++ b/README.md @@ -1 +1,114 @@ This is an attempt to port the original work https://github.com/ornl-epics/pvws to a Spring Boot-based web application. While the code is adapted to the web socket APIs of Spring Boot, most of the code is essentially unchanged. + +Endpoints +========= + + +`HTTP`/`HTTPS` +------ +`/pvws` + +This shows a webpage with documentation and test utilities. + +`pvws/info` + +This returns JSON information with general server info. + +`pvws/summary` + +This returns JSON with a summary of all active web sockets. + +`pvws/socket` + +This returns JSON with details on all active web sockets and their PVs. + +`pvws/pool` + +This returns JSON with a listing of all PVs in the PV connection pool. + +`pvws/pvget` + +This returns a single value read from a PV in the same JSON format as a websocket message. + +Takes the fully qualified PV address as the `name` parameter. + +`WS`/`WSS` +---------- + +`pvws/pv` + +This is the main websocket connection endpoint. See `/pvws` for information on commands. + +Requirements +------------ + +To build: JDK 17, Maven 3.8+ + +To run: JRE 17 + +Build +===== + +Executable jar +-------------- + +To build an executable Spring Boot jar (embedded Tomcat): + +``>mvn clean install`` + +Output is ``pvws.jar`` in the ``target`` directory. + +Tomcat war +---------- + +To build war for deployment in Tomcat container: + +``>mvn -Pwar clean install`` + +Output is ``pvws.war`` in the ``target`` directory. + +Run +=== + +Settings +-------- + +Bundled ``application.properties`` defines a few settings (e.g. default protocol). To define other settings or override +settings, one may create a file named exactly ``application.properties`` and edit as needed. This +file must be readable by the user account owning the application process. + +**NOTE:** If "ca" (channel access) is selected as default protocol, associated settings (e.g. EPICS_CA_ADDR_LIST) **must** +be defined as environment variables. + +Executable jar +-------------- + +Launch like so: + +``>java -Dspring.config.location=file:/path/to/directory/ -jar /path/to/pvws.jar`` + +where ``/path/to/directory/`` is the directory holding ``application.properties``. Note that +this string **must** end in a slash ("/"). + +It is also possible to define properties on the command line, e.g. + +``>java -Dspring.config.location=file:/path/to/directory/ -DEPICS_PVA_ADDR_LIST=1.2.3.4 -jar /path/to/pvws.jar`` + +Combining ``application.properties`` with JVM options is possible. +Command line options will override definitions in ``application.properties``. + +Tomcat war +---------- + +**NOTE:** Verified on Tomcat 9 only. + +Copy ``pvws.war`` to ``TOMCAT_ROOT/webapps``. Set environment variable: + +``>export JAVA_OPTS=-Dspring.config.location=file:/path/to/directory/`` + +and launch Tomcat. + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8db3631 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ + +services: + pvws: + build: + context: . + dockerfile: Dockerfile + image: pvws-springboot:latest + network_mode: "host" + environment: + - EPICS_CA_AUTO_ADDR_LIST=YES + - EPICS_PVA_AUTO_ADDR_LIST=YES diff --git a/src/main/java/org/phoebus/pvws/EpicsConfiguration.java b/src/main/java/org/phoebus/pvws/EpicsConfiguration.java index 246773d..1c8bfd6 100644 --- a/src/main/java/org/phoebus/pvws/EpicsConfiguration.java +++ b/src/main/java/org/phoebus/pvws/EpicsConfiguration.java @@ -61,12 +61,13 @@ public class EpicsConfiguration { @Value("${PV_WRITE_SUPPORT:true}") private String pvWriteSupport; + @Value("${PV_READ_TIMEOUT:5000}") + private String pvReadTimeout; + @PostConstruct public void init() { logger.log(Level.INFO, "==========================================="); logger.log(Level.INFO, contextPath + " started"); - logger.log(Level.INFO, "Supported PV types: " + PVPool.getSupportedPrefixes()); - // Set default type in preferences before PVPool reads the preferences String default_type = System.getenv("PV_DEFAULT_TYPE"); if (default_type != null && !default_type.isEmpty()) { @@ -77,6 +78,7 @@ else if (pvDefaultType != null && !pvDefaultType.isEmpty()) { Preferences.userRoot().node("/org/phoebus/pv").put("default", pvDefaultType); } logger.log(Level.INFO, "PV_DEFAULT_TYPE=" + Preferences.userRoot().node("/org/phoebus/pv").get("default", null)); + logger.log(Level.INFO, "Supported PV types: " + PVPool.getSupportedPrefixes()); Preferences.userRoot().node("/org/phoebus/pv/pva").put("epics_pva_addr_list", epicsPvaAddrList); @@ -90,6 +92,7 @@ else if (pvDefaultType != null && !pvDefaultType.isEmpty()) { System.setProperty("PV_THROTTLE_MS", pvThrottleMs); System.setProperty("PV_ARRAY_THROTTLE_MS", pvArrayThrottleMs); System.setProperty("PV_WRITE_SUPPORT", pvWriteSupport); + System.setProperty("PV_READ_TIMEOUT", pvReadTimeout); // Configure JCA/CAJ to use environment vars, not java properties or preferences System.setProperty("jca.use_env", "true"); diff --git a/src/main/java/org/phoebus/pvws/PvwsWebListener.java b/src/main/java/org/phoebus/pvws/PvwsWebListener.java index b176565..8137e74 100644 --- a/src/main/java/org/phoebus/pvws/PvwsWebListener.java +++ b/src/main/java/org/phoebus/pvws/PvwsWebListener.java @@ -38,7 +38,6 @@ public class PvwsWebListener implements ServletContextListener { @Override public void contextDestroyed(ServletContextEvent event) { - System.out.println("sdjhgfdfugh"); WebApplicationContext context = ContextLoader.getCurrentWebApplicationContext(); sockets = (List) context.getBean("sockets"); if (sockets == null) { diff --git a/src/main/java/org/phoebus/pvws/controllers/PvwsRestController.java b/src/main/java/org/phoebus/pvws/controllers/PvwsRestController.java index b000db6..e00db6f 100644 --- a/src/main/java/org/phoebus/pvws/controllers/PvwsRestController.java +++ b/src/main/java/org/phoebus/pvws/controllers/PvwsRestController.java @@ -22,19 +22,25 @@ import org.epics.util.array.ListInteger; import org.epics.vtype.Array; import org.epics.vtype.VType; +import org.phoebus.core.vtypes.VTypeHelper; import org.phoebus.pv.PV; import org.phoebus.pv.PVPool; import org.phoebus.pv.RefCountMap; import org.phoebus.pvws.model.*; +import org.phoebus.pvws.ws.Vtype2Json; import org.phoebus.pvws.ws.WebSocket; import org.phoebus.pvws.ws.WebSocketPV; import org.phoebus.util.time.TimestampFormats; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; @@ -146,4 +152,30 @@ public InfoData info(@RequestParam(name = "env", defaultValue = "false") boolean } return infoData; } + + @GetMapping(value= "/pvget", produces = MediaType.APPLICATION_JSON_VALUE) + public String pvget(@RequestParam String name) { + CountDownLatch countDownLatch = new CountDownLatch(1); + AtomicReference value = new AtomicReference<>(null); + try { + int pvReadTimeout = Integer.parseInt(System.getProperty("PV_READ_TIMEOUT")); + PV pv = PVPool.getPV(name); + pv.onValueEvent().subscribe(vtype -> { + if (!VTypeHelper.isDisconnected(vtype)) { + value.set(pv.read()); + } + countDownLatch.countDown(); + }); + countDownLatch.await(pvReadTimeout, TimeUnit.MILLISECONDS); + PVPool.releasePV(pv); + if(value.get() == null){ + logger.info("PV " + name + " never connected."); + return null; + } + return Vtype2Json.toJson(name, value.get(), null, true, true); + } catch (Exception e) { + logger.warning("Exception when reading PV " + name + " Exception: " + e); + return null; + } + } } diff --git a/src/main/java/org/phoebus/pvws/ws/Vtype2Json.java b/src/main/java/org/phoebus/pvws/ws/Vtype2Json.java index 5cecaf1..7170d77 100644 --- a/src/main/java/org/phoebus/pvws/ws/Vtype2Json.java +++ b/src/main/java/org/phoebus/pvws/ws/Vtype2Json.java @@ -17,7 +17,9 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.DoubleBuffer; +import java.nio.FloatBuffer; import java.nio.IntBuffer; +import java.nio.ShortBuffer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.text.DecimalFormat; @@ -58,15 +60,15 @@ else if (value instanceof VString) else if (value instanceof VEnum) handleEnum(g, (VEnum) value, last_value); else if (value instanceof VByteArray) - handleLongString(g, (VByteArray) value); + handleBytes(g, (VNumberArray) value, last_value); // Serialize double and float arrays as b64dbl else if (value instanceof VDoubleArray) handleDoubles(g, (VNumberArray) value, last_value); else if (value instanceof VFloatArray) - handleDoubles(g, (VNumberArray) value, last_value); + handleFloats(g, (VNumberArray) value, last_value); - // Serialize remaining number arrays (int, short) as b64int + // Serialize remaining number arrays (int) as b64int else if (value instanceof VNumberArray) handleInts(g, (VNumberArray) value, last_value); @@ -239,7 +241,7 @@ private static void handleInts(final JsonGenerator g, final VNumberArray value, { // Add severity if it changed if ((last_value instanceof VNumber) && - ((VNumber) last_value).getAlarm().getSeverity() != severity) + ((VNumber) last_value).getAlarm().getSeverity() != severity) g.writeStringField("severity", severity.name()); } @@ -256,6 +258,7 @@ private static void handleInts(final JsonGenerator g, final VNumberArray value, } + private static void handleEnum(final JsonGenerator g, final VEnum value, final VType last_value) throws Exception { final AlarmSeverity severity = value.getAlarm().getSeverity(); @@ -275,11 +278,98 @@ private static void handleEnum(final JsonGenerator g, final VEnum value, final V { // Add severity if it changed if ((last_value instanceof VNumber) && - ((VNumber) last_value).getAlarm().getSeverity() != severity) + ((VNumber) last_value).getAlarm().getSeverity() != severity) g.writeStringField("severity", severity.name()); } g.writeNumberField("value", value.getIndex()); g.writeStringField("text", value.getValue()); } + + private static void handleFloats(final JsonGenerator g, final VNumberArray value, final VType last_value) throws Exception + { + final AlarmSeverity severity = value.getAlarm().getSeverity(); + if (last_value == null) + { + // Initially, add complete metadata + g.writeStringField("vtype", VType.typeOf(value).getSimpleName()); + handleDisplay(g, value.getDisplay()); + // Initial severity + g.writeStringField("severity", severity.name()); + } + else + { + // Add severity if it changed + if ((last_value instanceof VNumber) && + ((VNumber) last_value).getAlarm().getSeverity() != severity) + g.writeStringField("severity", severity.name()); + } + + final ListNumber data = value.getData(); + final int N = data.size(); + final ByteBuffer buf = ByteBuffer.allocate(N * Float.BYTES); + buf.order(ByteOrder.LITTLE_ENDIAN); + final FloatBuffer fltbuf = buf.asFloatBuffer(); + for (int i=0; i this.handleConnection(); + this.socket.onopen = event => this.handleConnection(event); this.socket.onmessage = event => this.handleMessage(event.data); this.socket.onclose = event => this.handleClose(event); this.socket.onerror = event => this.handleError(event); } - - handleConnection() + + handleConnection(event) { console.log("Connected to " + this.url); this.connect_handler(true); + + // Start idle check + if (this.idle_timer == null) + this.idle_timer = setInterval(() => this.checkIdleTimeout(), this.idle_check_ms); } - + + checkIdleTimeout() + { + if (this.idle) + { + // console.log("Idle connection " + this.url); + this.ping(); + } + else + { + // console.log("Active connection " + this.url); + // Reset to detect new messages + this.idle = true; + } + } + + stopIdleCheck() + { + if (this.idle_timer != null) + clearInterval(this.idle_timer); + this.idle_timer = null; + } + handleMessage(message) { // console.log("Received Message: " + message); + this.idle = false; let jm = JSON.parse(message); if (jm.type === "update") { @@ -62,6 +103,22 @@ class PVWS // console.log(JSON.stringify(jm.value)); delete jm.b64dbl; } + else if (jm.b64flt !== undefined) + { + let bytes = toByteArray(jm.b64flt); + jm.value = new Float32Array(bytes.buffer); + // Convert to plain array + jm.value = Array.prototype.slice.call(jm.value); + delete jm.b64flt; + } + else if (jm.b64srt !== undefined) + { + let bytes = toByteArray(jm.b64srt); + jm.value = new Int16Array(bytes.buffer); + // Convert to plain array + jm.value = Array.prototype.slice.call(jm.value); + delete jm.b64srt; + } else if (jm.b64int !== undefined) { let bytes = toByteArray(jm.b64int); @@ -70,14 +127,22 @@ class PVWS jm.value = Array.prototype.slice.call(jm.value); delete jm.b64int; } - + else if (jm.b64byt !== undefined) + { + let bytes = toByteArray(jm.b64byt); + jm.value = new Uint8Array(bytes.buffer); + // Convert to plain array, if necessary + jm.value = Array.prototype.slice.call(jm.value); + delete jm.b64byt; + } + // Merge received data with last known value let value = this.values[jm.pv]; // No previous value: // Default to read-only, no data if (value === undefined) value = { pv: jm.pv, readonly: true }; - + // Update cached value with received changes Object.assign(value, jm); this.values[jm.pv] = value; @@ -94,9 +159,10 @@ class PVWS console.error(event); this.close(); } - + handleClose(event) { + this.stopIdleCheck(); this.connect_handler(false); let message = "Web socket closed (" + event.code ; if (event.reason) @@ -114,9 +180,10 @@ class PVWS */ ping() { + console.log("Sending ping to " + this.url); this.socket.send(JSON.stringify({ type: "ping" })) } - + /** Subscribe to one or more PVs * @param pvs PV name or array of PV names */ @@ -124,7 +191,6 @@ class PVWS { if (pvs.constructor !== Array) pvs = [ pvs ]; - // TODO Remember all PVs so we can re-subscribe after close/re-open this.socket.send(JSON.stringify({ type: "subscribe", pvs: pvs })); } @@ -135,21 +201,20 @@ class PVWS { if (pvs.constructor !== Array) pvs = [ pvs ]; - // TODO Forget PVs so we don't re-subscribe after close/re-open this.socket.send(JSON.stringify({ type: "clear", pvs: pvs })); - + // Remove entry for cleared PVs from this.values let pv; for (pv of pvs) delete this.values[pv]; } - + /** Request list of PVs */ list() { this.socket.send(JSON.stringify({ type: "list" })); } - + /** Write to PV * @param pvs PV name * @param value number or string @@ -158,17 +223,20 @@ class PVWS { this.socket.send(JSON.stringify({ type: "write", pv: pv, value: value })); } - + /** Close the web socket. - * + * *

Socket will automatically re-open, * similar to handling an error. */ close() { + this.stopIdleCheck(); this.socket.close(); } } -// TODO Larger timeout for production setup -PVWS.prototype.reconnect_ms = 5000; +// Attempt re-connect after 10 seconds +PVWS.prototype.reconnect_ms = 10000; +// Perform idle check every 30 secs +PVWS.prototype.idle_check_ms = 30000; \ No newline at end of file