diff --git a/.env b/.env index 3859776a..0db0d5d7 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # React app settings. Restart the process for changes to take effect. # Set to connect to a Coniql server. -# REACT_APP_CONIQL_SOCKET=cs03r-sc-serv-40.diamond.ac.uk:8080 +# REACT_APP_CONIQL_SOCKET=cs03r-sc-serv-39.diamond.ac.uk:8080 REACT_APP_BASE_URL=http://localhost:3000 REACT_APP_SIMULATION_TIME=100 diff --git a/public/json/device.json b/public/json/device.json new file mode 100644 index 00000000..967ce615 --- /dev/null +++ b/public/json/device.json @@ -0,0 +1,123 @@ +{ + "type": "display", + "position": "relative", + "overflow": "auto", + "backgroundColor": "#012265", + "children": [ + { + "type": "shape", + "position": "relative", + "height": "5vh", + "width": "100%", + "backgroundColor": "#012265" + }, + { + "type": "slideshow", + "position": "relative", + "width": "90%", + "margin": "auto", + "overflow": "auto", + "backgroundColor": "white", + "border": { + "style": "line", + "width": 5, + "color": "#ffcf00", + "radius": 10 + }, + "children": [ + { + "type": "display", + "position": "relative", + "width": "100%", + "backgroundColor": "#cccccc", + "overflow": "auto", + "children": [ + { + "type": "label", + "position": "relative", + "height": "4rem", + "textAlign": "center", + "font": { + "style": "bold", + "size": 20 + }, + "backgroundColor": "#eeeeee", + "text": "Device Test" + }, + { + "type": "embeddedDisplay", + "position": "relative", + "file": "xpress.json", + "filetype": "json", + "defaultProtocol": "ca" + } + ] + }, + { + "type": "display", + "position": "relative", + "width": "100%", + "backgroundColor": "#cccccc", + "overflow": "auto", + "macros": { + "motor": "1" + }, + "children": [ + { + "type": "label", + "position": "relative", + "width": "100%", + "height": "4rem", + "textAlign": "center", + "font": { + "style": "bold", + "size": 30 + }, + "backgroundColor": "#eeeeee", + "text": "Embed" + }, + { + "type": "dynamicpage", + "position": "relative", + "routePath": "embed" + } + ] + }, + { + "type": "display", + "position": "relative", + "width": "100%", + "backgroundColor": "#cccccc", + "overflow": "auto", + "children": [ + { + "type": "label", + "position": "relative", + "width": "100%", + "height": "4rem", + "textAlign": "center", + "font": { + "style": "bold", + "size": 30 + }, + "backgroundColor": "#eeeeee", + "text": "Detail" + }, + { + "type": "dynamicpage", + "position": "relative", + "routePath": "detail" + } + ] + } + ] + }, + { + "type": "shape", + "position": "relative", + "height": "5vh", + "width": "100%", + "backgroundColor": "#012265" + } + ] +} diff --git a/public/json/drawer.json b/public/json/drawer.json index 9deb0734..2a9d93e1 100644 --- a/public/json/drawer.json +++ b/public/json/drawer.json @@ -103,6 +103,42 @@ ] } }, + { + "type": "actionbutton", + "text": "Device Testing", + "position": "relative", + "width": "100%", + "height": "4rem", + "margin": "1% 5% 1% 5%", + "foregroundColor": "#012265", + "backgroundColor": "#ffcf00", + "font": { + "size": "25" + }, + "border": { + "style": "none", + "color": "#ffcf00", + "width": 1 + }, + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "dynamicInfo": { + "name": "app", + "location": "app", + "description": "", + "file": { + "path": "xpress.json", + "macros": {}, + "defaultProtocol": "pva" + } + } + } + ] + } + }, { "type": "actionbutton", "text": "Mobile Layout", diff --git a/public/json/menu.json b/public/json/menu.json new file mode 100644 index 00000000..62108574 --- /dev/null +++ b/public/json/menu.json @@ -0,0 +1,164 @@ +{ + "type": "flexcontainer", + "position": "relative", + "children": [ + { + "type": "actionbutton", + "text": "Beamline Synoptic", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#4da6ff", + "font": { + "size": 30 + }, + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "page": "app", + "pageDescription": { + "filename": "beamline", + "filetype": "json", + "macros": {} + } + } + } + ] + } + }, + { + "type": "actionbutton", + "text": "Mobile Layout", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#ffa64d", + "font": { + "size": 30 + }, + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "page": "app", + "pageDescription": { + "filename": "mobileDemo", + "filetype": "json", + "macros": {} + } + } + } + ] + } + }, + { + "type": "actionbutton", + "text": "Device Testing", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#ffa64d", + "font": { + "size": 30 + }, + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "page": "app", + "pageDescription": { + "filename": "device", + "filetype": "json", + "macros": {} + } + } + } + ] + } + }, + { + "type": "actionbutton", + "text": "Performance Testing", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#4dffa6", + "font": { + "size": 30 + }, + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "page": "app", + "pageDescription": { + "filename": "performance", + "filetype": "json", + "macros": {} + } + } + } + ] + } + }, + { + "type": "actionbutton", + "text": "Machine Status", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#e580ff", + "fontSize": "3rem", + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "location": "app", + "page": "machineStatus", + "macros": "{}" + } + } + ] + } + }, + { + "type": "actionbutton", + "text": "Modern Machine Status", + "position": "relative", + "width": "300px", + "height": "200px", + "margin": "10px", + "backgroundColor": "#ff7070", + "fontSize": "3rem", + "actions": { + "executeAsOne": false, + "actions": [ + { + "type": "OPEN_PAGE", + "openPageInfo": { + "location": "app", + "page": "modernMachineStatus", + "macros": "{}" + } + } + ] + } + } + ] +} diff --git a/public/json/opiTesting.json b/public/json/opiTesting.json index a7a05dbb..cc4e673a 100644 --- a/public/json/opiTesting.json +++ b/public/json/opiTesting.json @@ -7,7 +7,7 @@ "position": "relative", "margin": "10px", "file": { - "path": "synoptic.opi", + "path": "motor_detail.opi", "macros": {}, "defaultProtocol": "ca" } diff --git a/public/json/xpress.json b/public/json/xpress.json new file mode 100644 index 00000000..741bd9c1 --- /dev/null +++ b/public/json/xpress.json @@ -0,0 +1,13 @@ +{ + "type": "display", + "position": "relative", + "width": "100%", + "children": [ + { + "type": "device", + "deviceName": "device://Xspress3", + "position": "relative", + "width": "100%" + } + ] +} diff --git a/src/connection/coniql.test.ts b/src/connection/coniql.test.ts index 9c351ed5..b49629ee 100644 --- a/src/connection/coniql.test.ts +++ b/src/connection/coniql.test.ts @@ -1,9 +1,10 @@ import { ApolloClient } from "apollo-client"; import { - ConiqlPlugin, + ConiqlPvPlugin, ConiqlStatus, ConiqlTime, - ConiqlBase64Array + ConiqlBase64Array, + ConiqlDevicePlugin } from "./coniql"; import { DType } from "../types/dtypes"; @@ -47,15 +48,51 @@ class MockObservable { } } -describe("ConiqlPlugin", (): void => { - let cp: ConiqlPlugin; +describe("ConiqlDevicePlugin", (): void => { + let cp: ConiqlDevicePlugin; let mockConnUpdate: jest.Mock; let mockValUpdate: jest.Mock; + let mockDevUpdate: jest.Mock; beforeEach((): void => { - cp = new ConiqlPlugin("a.b.c:100"); + cp = new ConiqlDevicePlugin("x.y.z:1000"); mockConnUpdate = jest.fn(); mockValUpdate = jest.fn(); - cp.connect(mockConnUpdate, mockValUpdate); + mockDevUpdate = jest.fn(); + cp.connect(mockConnUpdate, mockValUpdate, mockDevUpdate); + }); + + it("subscribes to a device", (): void => { + ApolloClient.prototype.subscribe = jest.fn( + (_): MockObservable => new MockObservable(42) + ) as jest.Mock; + cp.subscribe("pvDevice", {}); + expect(ApolloClient.prototype.subscribe).toHaveBeenCalled(); + expect(mockDevUpdate).toHaveBeenCalledWith( + "pvDevice", + new DType({ + stringValue: JSON.stringify({ + subscribeChannel: { + value: { + float: 42 + } + } + }) + }) + ); + }); +}); + +describe("ConiqlPvPlugin", (): void => { + let cp: ConiqlPvPlugin; + let mockConnUpdate: jest.Mock; + let mockValUpdate: jest.Mock; + let mockDevUpdate: jest.Mock; + beforeEach((): void => { + cp = new ConiqlPvPlugin("a.b.c:100"); + mockConnUpdate = jest.fn(); + mockValUpdate = jest.fn(); + mockDevUpdate = jest.fn(); + cp.connect(mockConnUpdate, mockValUpdate, mockDevUpdate); }); it("handles update to value", (): void => { diff --git a/src/connection/coniql.ts b/src/connection/coniql.ts index bd084e21..28c756f0 100644 --- a/src/connection/coniql.ts +++ b/src/connection/coniql.ts @@ -17,12 +17,16 @@ import { import { onError } from "apollo-link-error"; import introspectionQueryResultData from "./fragmentTypes.json"; import { - Connection, ConnectionChangedCallback, ValueChangedCallback, nullConnCallback, nullValueCallback, - SubscriptionType + SubscriptionType, + Connection, + ConiqlDeviceConnection, + PvConnection, + DeviceChangedCallback, + nullDeviceCallback } from "./plugin"; import { SubscriptionClient } from "subscriptions-transport-ws"; import { @@ -234,16 +238,45 @@ const PV_MUTATION = gql` } `; -export class ConiqlPlugin implements Connection { - private client: ApolloClient; - private onConnectionUpdate: ConnectionChangedCallback; - private onValueUpdate: ValueChangedCallback; +const DEVICE_SUBSCRIPTION = gql` + query deviceQuery($pvDevice: ID!) { + getDevice(id: $pvDevice) { + id + children(flatten: true) { + name + label + child { + __typename + ... on Channel { + id + } + ... on Device { + id + } + ... on Group { + layout + children { + name + } + } + } + } + } + } +`; + +class ConiqlPlugin implements Connection { + protected _client: ApolloClient; + protected _onConnectionUpdate: ConnectionChangedCallback; + protected _onValueUpdate: ValueChangedCallback; + protected _onDeviceUpdate: DeviceChangedCallback; private connected: boolean; private wsClient: SubscriptionClient; - private disconnected: string[] = []; - private subscriptions: { [pvName: string]: Subscription }; + protected _disconnected: string[] = []; + // NOTE: This class handles devices and PVs hence pvDevice + private subscriptions: { [pvDevice: string]: Subscription }; - public constructor(socket: string) { + public constructor(socket: string, type: string) { const fragmentMatcher = new IntrospectionFragmentMatcher({ introspectionQueryResultData }); @@ -252,38 +285,49 @@ export class ConiqlPlugin implements Connection { this.wsClient = new SubscriptionClient(`ws://${socket}/ws`, { reconnect: true }); + this.wsClient.onReconnecting((): void => { - for (const pvName of this.disconnected) { - this._subscribe(pvName); + for (const pvDevice of this._disconnected) { + this._subscribe(pvDevice); } - this.disconnected = []; + this._disconnected = []; }); + this.wsClient.onDisconnected((): void => { log.error("Websockect client disconnected."); - for (const pvName of Object.keys(this.subscriptions)) { - if ( - this.subscriptions.hasOwnProperty(pvName) && - this.subscriptions[pvName] - ) { - this.subscriptions[pvName].unsubscribe(); - delete this.subscriptions[pvName]; - } else { - log.error(`Attempt to unsubscribe from ${pvName} failed`); - } - this.onConnectionUpdate(pvName, { + for (const pvDevice of Object.keys(this.subscriptions)) { + this.unsubscribe(pvDevice); + this._onConnectionUpdate(pvDevice, type, { isConnected: false, isReadonly: true }); } }); + const link = this.createLink(socket); - this.client = new ApolloClient({ link, cache }); - this.onConnectionUpdate = nullConnCallback; - this.onValueUpdate = nullValueCallback; + this._client = new ApolloClient({ link, cache }); + this._onConnectionUpdate = nullConnCallback; + this._onValueUpdate = nullValueCallback; + this._onDeviceUpdate = nullDeviceCallback; this.connected = false; this.subscriptions = {}; } + public connect( + connectionCallback: ConnectionChangedCallback, + valueCallback: ValueChangedCallback, + deviceCallback: DeviceChangedCallback + ): void { + this._onConnectionUpdate = connectionCallback; + this._onValueUpdate = valueCallback; + this._onDeviceUpdate = deviceCallback; + this.connected = true; + } + + public isConnected(): boolean { + return this.connected; + } + public createLink(socket: string): ApolloLink { const wsLink = new WebSocketLink(this.wsClient); const errorLink = onError(({ graphQLErrors, networkError }) => { @@ -315,34 +359,92 @@ export class ConiqlPlugin implements Connection { return link; } - public connect( - connectionCallback: ConnectionChangedCallback, - valueCallback: ValueChangedCallback - ): void { - this.onConnectionUpdate = connectionCallback; - this.onValueUpdate = valueCallback; - this.connected = true; + /** + * This should be overridden in the child class, and it's implementation + * will depend on the type of subscription (currently device of PV) + * @param pvDevice + */ + protected _subscribe(pvDevice: string): Subscription { + throw new Error("_subscribe method not implemented"); } - public isConnected(): boolean { - return this.connected; + public subscribe(pvDevice: string, type: SubscriptionType): string { + if (this.subscriptions[pvDevice] === undefined) { + this.subscriptions[pvDevice] = this._subscribe(pvDevice); + } + return pvDevice; + } + + public unsubscribe(pvDevice: string): void { + if ( + this.subscriptions.hasOwnProperty(pvDevice) && + this.subscriptions[pvDevice] + ) { + this.subscriptions[pvDevice].unsubscribe(); + delete this.subscriptions[pvDevice]; + } else { + log.error(`Attempt to unsubscribe from ${pvDevice} failed`); + } + } +} + +export class ConiqlDevicePlugin extends ConiqlPlugin + implements ConiqlDeviceConnection { + constructor(socket: string) { + super(socket, "device"); + } + + private _process(data: any, device: string, operation: string): void { + this._onDeviceUpdate( + device, + new DType({ stringValue: JSON.stringify(data.data) }) + ); + } + + protected _subscribe(pvDevice: string): Subscription { + return this._client + .subscribe({ + query: DEVICE_SUBSCRIPTION, + variables: { pvDevice: pvDevice.split("://")[1] } + }) + .subscribe({ + next: (data): void => { + this._process(data, pvDevice, "subscribeDevice"); + }, + error: (err): void => { + log.error("err", err); + }, + complete: (): void => { + this._onConnectionUpdate(pvDevice, "device", { + isConnected: false, + isReadonly: true + }); + this._disconnected.push(pvDevice); + } + }); + } +} + +export class ConiqlPvPlugin extends ConiqlPlugin implements PvConnection { + constructor(socket: string) { + super(socket, "pv"); } private _process(data: any, pvName: string, operation: string): void { // Process an update to a channel either from getChannel or subscribeChannel. const { value, time, status, display } = data.data[operation]; if (status) { - this.onConnectionUpdate(pvName, { + this._onConnectionUpdate(pvName, "pv", { isConnected: true, isReadonly: !status.mutable }); } const dtype = coniqlToDType(value, time, status, display); - this.onValueUpdate(pvName, dtype); + this._onValueUpdate(pvName, dtype); } - private _subscribe(pvName: string): Subscription { - return this.client + protected _subscribe(pvName: string): Subscription { + return this._client .subscribe({ query: PV_SUBSCRIPTION, variables: { pvName: pvName } @@ -356,45 +458,26 @@ export class ConiqlPlugin implements Connection { }, complete: (): void => { // complete is called when the websocket is disconnected. - this.onConnectionUpdate(pvName, { + this._onConnectionUpdate(pvName, "pv", { isConnected: false, isReadonly: true }); - this.disconnected.push(pvName); + this._disconnected.push(pvName); } }); } - public subscribe(pvName: string, type: SubscriptionType): string { - // TODO: How to handle multiple subscriptions of different types to the same channel? - if (this.subscriptions[pvName] === undefined) { - this.subscriptions[pvName] = this._subscribe(pvName); - } - return pvName; - } - public putPv(pvName: string, value: DType): void { log.debug(`Putting ${value} to ${pvName}.`); const variables = { pvName: pvName, value: DType.coerceString(value) }; - this.client + this._client .mutate({ mutation: PV_MUTATION, variables: variables }) .catch(error => { log.error(`Failed to write ${value} to ${pvName}`); log.error(error); }); } - - public unsubscribe(pvName: string): void { - // Note that connectionMiddleware handles multiple subscriptions - // for the same PV at present, so if this method is called then - // there is no further need for this PV. - // Note that a bug in tartiflette-aiohttp means that the - // unsubscribe does not work on Python 3.8. - // https://github.com/tartiflette/tartiflette-aiohttp/pull/81 - this.subscriptions[pvName].unsubscribe(); - delete this.subscriptions[pvName]; - } } diff --git a/src/connection/forwarder.ts b/src/connection/forwarder.ts index 7a644e9f..03a4b3b6 100644 --- a/src/connection/forwarder.ts +++ b/src/connection/forwarder.ts @@ -1,8 +1,11 @@ import { Connection, + ConiqlDeviceConnection, ConnectionChangedCallback, ValueChangedCallback, - SubscriptionType + SubscriptionType, + DeviceChangedCallback, + PvConnection } from "./plugin"; export class ConnectionForwarder implements Connection { @@ -12,9 +15,11 @@ export class ConnectionForwarder implements Connection { this.prefixConnections = prefixConnections; this.connected = false; } - private getConnection(pvName: string): Connection { + private getConnection( + pvDevice: string + ): ConiqlDeviceConnection | PvConnection { for (const [prefix, connection] of this.prefixConnections) { - if (pvName.startsWith(prefix)) { + if (pvDevice.startsWith(prefix)) { if (connection !== undefined) { return connection; } else { @@ -22,36 +27,37 @@ export class ConnectionForwarder implements Connection { } } } - throw new Error(`No connections for ${pvName}`); + throw new Error(`No connections for ${pvDevice}`); } - public subscribe(pvName: string, type: SubscriptionType): string { - const connection = this.getConnection(pvName); - return connection.subscribe(pvName, type); + public subscribe(pvDevice: string, type: SubscriptionType): string { + const connection = this.getConnection(pvDevice); + return connection.subscribe(pvDevice, type); } - public unsubscribe(pvName: string): void { - const connection = this.getConnection(pvName); - return connection.unsubscribe(pvName); + public unsubscribe(pvDevice: string): void { + const connection = this.getConnection(pvDevice); + return connection.unsubscribe(pvDevice); } public isConnected(): boolean { return this.connected; } - public putPv(pvName: string, value: any): void { - const connection = this.getConnection(pvName); - return connection.putPv(pvName, value); + public putPv(pvDevice: string, value: any): void { + const connection = this.getConnection(pvDevice) as PvConnection; + return connection.putPv(pvDevice, value); } public connect( connectionCallback: ConnectionChangedCallback, - valueCallback: ValueChangedCallback + valueCallback: ValueChangedCallback, + deviceCallback: DeviceChangedCallback ): void { for (const [, connection] of this.prefixConnections) { if (connection !== undefined) { if (!connection.isConnected()) { - connection.connect(connectionCallback, valueCallback); + connection.connect(connectionCallback, valueCallback, deviceCallback); } } } diff --git a/src/connection/plugin.ts b/src/connection/plugin.ts index 4df4de0b..c3bf091f 100644 --- a/src/connection/plugin.ts +++ b/src/connection/plugin.ts @@ -11,6 +11,7 @@ export interface SubscriptionType { export const nullConnCallback: ConnectionChangedCallback = (_p, _v): void => {}; export const nullValueCallback: ValueChangedCallback = (_p, _v): void => {}; +export const nullDeviceCallback: DeviceChangedCallback = (_d, _v): void => {}; export interface ConnectionState { isConnected: boolean; @@ -18,18 +19,30 @@ export interface ConnectionState { } export type ConnectionChangedCallback = ( - pvName: string, + pvDevice: string, + type: string, value: ConnectionState ) => void; export type ValueChangedCallback = (pvName: string, value: DType) => void; +export type DeviceChangedCallback = (device: string, value: DType) => void; -export interface Connection { - subscribe: (pvName: string, type: SubscriptionType) => string; // must be idempotent - putPv: (pvName: string, value: DType) => void; +export type Connection = { + subscribe: (pvDevice: string, type: SubscriptionType) => string; // must be idempotent connect: ( connectionCallback: ConnectionChangedCallback, - valueCallback: ValueChangedCallback + valueCallback: ValueChangedCallback, + deviceCallback: DeviceChangedCallback ) => void; isConnected: () => boolean; unsubscribe: (pvName: string) => void; +}; + +// Left for easy expansion and easier to understand type definitions +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ConiqlDeviceConnection extends Connection {} + +export interface PvConnection extends Connection { + putPv: (pvName: string, value: DType) => void; } + +export type ConnectionTypes = PvConnection | ConiqlDeviceConnection; diff --git a/src/connection/sim.test.ts b/src/connection/sim.test.ts index bd46ce56..2fc34104 100644 --- a/src/connection/sim.test.ts +++ b/src/connection/sim.test.ts @@ -1,5 +1,9 @@ import { SimulatorPlugin } from "./sim"; -import { nullConnCallback, nullValueCallback } from "./plugin"; +import { + nullConnCallback, + nullValueCallback, + nullDeviceCallback +} from "./plugin"; import { ddouble, dstring } from "../setupTests"; import { DType } from "../types/dtypes"; @@ -9,13 +13,17 @@ beforeEach((): void => { }); function getValue(pvName: string, callback: Function): void { - simulator.connect(nullConnCallback, function(updatePvName, value): void { - const nameInfo1 = SimulatorPlugin.parseName(updatePvName); - const nameInfo2 = SimulatorPlugin.parseName(updatePvName); - if (nameInfo1.keyName === nameInfo2.keyName) { - callback(value); - } - }); + simulator.connect( + nullConnCallback, + function(updatePvName, value): void { + const nameInfo1 = SimulatorPlugin.parseName(updatePvName); + const nameInfo2 = SimulatorPlugin.parseName(updatePvName); + if (nameInfo1.keyName === nameInfo2.keyName) { + callback(value); + } + }, + nullDeviceCallback + ); } const assertValue = ( @@ -209,9 +217,13 @@ describe("LimitData", (): void => { const iter = repeatedCallback(); iter.next(); - simulator.connect(nullConnCallback, function(name, value): void { - iter.next({ name: name, value: value }); - }); + simulator.connect( + nullConnCallback, + function(name, value): void { + iter.next({ name: name, value: value }); + }, + nullDeviceCallback + ); simulator.subscribe("sim://limit#one"); simulator.subscribe("sim://limit#two"); @@ -224,7 +236,7 @@ it("test disconnector", (done): void => { let wasConnected = false; let wasDisconnected = false; simulator = new SimulatorPlugin(50); - function callback(pvName: string, state: any): void { + function callback(pvName: string, type: string, state: any): void { expect(pvName).toBe("sim://disconnector"); if (state.isConnected) { wasConnected = true; @@ -236,8 +248,7 @@ it("test disconnector", (done): void => { done(); } } - - simulator.connect(callback, nullValueCallback); + simulator.connect(callback, nullValueCallback, nullDeviceCallback); simulator.subscribe("sim://disconnector"); }); @@ -288,9 +299,13 @@ it("distinguish sine values", (done): void => { } } - simulator.connect(nullConnCallback, function(name, value): void { - callback({ name: name, value: value }); - }); + simulator.connect( + nullConnCallback, + function(name, value): void { + callback({ name: name, value: value }); + }, + nullDeviceCallback + ); simulator.subscribe("sim://sine#one"); simulator.subscribe("sim://sine#two"); @@ -400,7 +415,8 @@ it("unsubscribe stops updates for simulated values", (done): void => { simulator.connect( nullConnCallback, - callbacks.callback(client.callback(callback)) + callbacks.callback(client.callback(callback)), + nullDeviceCallback ); client.subscribe(); }); @@ -436,7 +452,8 @@ it("unsubscribe stops updates, but maintains value", (done): void => { simulator.connect( nullConnCallback, - callbacks.callback(client.callback(callback)) + callbacks.callback(client.callback(callback)), + nullDeviceCallback ); client.subscribe(); }); diff --git a/src/connection/sim.ts b/src/connection/sim.ts index ee294f0d..541bcdf4 100644 --- a/src/connection/sim.ts +++ b/src/connection/sim.ts @@ -1,11 +1,13 @@ import log from "loglevel"; import { - Connection, ConnectionState, ConnectionChangedCallback, ValueChangedCallback, + DeviceChangedCallback, nullConnCallback, - nullValueCallback + nullValueCallback, + nullDeviceCallback, + PvConnection } from "./plugin"; import { DType, @@ -64,7 +66,7 @@ abstract class SimPv { } public publishConnection(): void { - this.onConnectionUpdate(this.pvName, this.getConnection()); + this.onConnectionUpdate(this.pvName, "pv", this.getConnection()); } public updateValue(_: DType): void { @@ -334,10 +336,11 @@ class SimCache { } } -export class SimulatorPlugin implements Connection { +export class SimulatorPlugin implements PvConnection { private simPvs: SimCache; private onConnectionUpdate: ConnectionChangedCallback; private onValueUpdate: ValueChangedCallback; + private onDeviceUpdate: DeviceChangedCallback; private updateRate: number; private connected: boolean; @@ -345,6 +348,7 @@ export class SimulatorPlugin implements Connection { this.simPvs = new SimCache(); this.onConnectionUpdate = nullConnCallback; this.onValueUpdate = nullValueCallback; + this.onDeviceUpdate = nullDeviceCallback; this.putPv = this.putPv.bind(this); this.updateRate = updateRate || 2000; this.connected = false; @@ -360,7 +364,8 @@ export class SimulatorPlugin implements Connection { public connect( connectionCallback: ConnectionChangedCallback, - valueCallback: ValueChangedCallback + valueCallback: ValueChangedCallback, + deviceCallback: DeviceChangedCallback ): void { if (this.connected) { throw new Error("Can only connect once"); @@ -368,6 +373,7 @@ export class SimulatorPlugin implements Connection { this.onConnectionUpdate = connectionCallback; this.onValueUpdate = valueCallback; + this.onDeviceUpdate = deviceCallback; this.connected = true; } diff --git a/src/redux/actions.ts b/src/redux/actions.ts index 5a3dbfbf..ca5e86c2 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -7,20 +7,16 @@ export const UNSUBSCRIBE = "unsubscribe"; export const VALUE_CHANGED = "value_changed"; export const VALUES_CHANGED = "values_changed"; export const WRITE_PV = "write_pv"; - -/* The never type in the constructor ensures that TypeScript - won't allow this error to be created. This is useful in - switch blocks to check that all cases have been handled. */ -export class InvalidAction extends Error { - public constructor(val: never) { - super(`Invalid action: ${val}`); - } -} +export const SUBSCRIBE_DEVICE = "subscribe_device"; +export const DEVICE_CHANGED = "device_changed"; +export const DEVICES_CHANGED = "devices_changed"; +export const UNSUBSCRIBE_DEVICE = "unsubscribe_device"; export interface ConnectionChanged { type: typeof CONNECTION_CHANGED; payload: { - pvName: string; + pvDevice: string; + type: string; value: ConnectionState; }; } @@ -56,6 +52,36 @@ export interface ValuesChanged { payload: ValueChanged[]; } +export interface SubscribeDevice { + type: typeof SUBSCRIBE_DEVICE; + payload: { + device: string; + componentId: string; + }; +} + +export interface UnsubscribeDevice { + type: typeof UNSUBSCRIBE_DEVICE; + payload: { + componentId: string; + device: string; + }; +} + +export interface DeviceChanged { + type: typeof DEVICE_CHANGED; + payload: { + device: string; + componentId: string; + value: DType; + }; +} + +export interface DevicesChanged { + type: typeof DEVICES_CHANGED; + payload: DeviceChanged[]; +} + export interface WritePv { type: typeof WRITE_PV; payload: { @@ -70,4 +96,8 @@ export type Action = | Unsubscribe | ValueChanged | ValuesChanged - | WritePv; + | WritePv + | DeviceChanged + | DevicesChanged + | UnsubscribeDevice + | SubscribeDevice; diff --git a/src/redux/connectionMiddleware.test.ts b/src/redux/connectionMiddleware.test.ts index 3192fa4c..8d657fa9 100644 --- a/src/redux/connectionMiddleware.test.ts +++ b/src/redux/connectionMiddleware.test.ts @@ -1,4 +1,11 @@ -import { SUBSCRIBE, Subscribe, WRITE_PV, WritePv } from "./actions"; +import { + SUBSCRIBE, + Subscribe, + WRITE_PV, + WritePv, + SUBSCRIBE_DEVICE, + SubscribeDevice +} from "./actions"; import { connectionMiddleware } from "./connectionMiddleware"; import { ddouble } from "../setupTests"; @@ -41,6 +48,26 @@ describe("connectionMiddleware", (): void => { expect(mockNext).toHaveBeenCalledTimes(1); expect(mockNext.mock.calls[0][0].type).toEqual(SUBSCRIBE); }); + it("calls subscribe() when receiving SUBSCRIBE_DEVICE", (): void => { + const middleware = connectionMiddleware(mockConnection); + // nextHandler takes next() and returns the actual middleware function + const nextHandler = middleware(mockStore); + const mockNext = jest.fn(); + // actionHandler takes an action + const actionHandler = nextHandler(mockNext); + const subscribeAction: SubscribeDevice = { + type: SUBSCRIBE_DEVICE, + payload: { + device: "device", + componentId: "2" + } + }; + actionHandler(subscribeAction); + expect(mockConnection.subscribe).toHaveBeenCalledTimes(1); + // The action is passed on. + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext.mock.calls[0][0].type).toEqual(SUBSCRIBE_DEVICE); + }); it("calls putPv() when receiving WritePv", (): void => { // Set up state mockStore.getState.mockReturnValue({ effectivePvNameMap: {} }); diff --git a/src/redux/connectionMiddleware.ts b/src/redux/connectionMiddleware.ts index 136ff96a..26440116 100644 --- a/src/redux/connectionMiddleware.ts +++ b/src/redux/connectionMiddleware.ts @@ -1,24 +1,33 @@ import log from "loglevel"; import { MiddlewareAPI, Dispatch } from "redux"; -import { Connection, ConnectionState } from "../connection/plugin"; import { - CONNECTION_CHANGED, + ConnectionState, + ConnectionTypes, + PvConnection +} from "../connection/plugin"; +import { SUBSCRIBE, WRITE_PV, VALUE_CHANGED, + DEVICE_CHANGED, + CONNECTION_CHANGED, + SUBSCRIBE_DEVICE, UNSUBSCRIBE, - Action + Action, + SubscribeDevice, + UNSUBSCRIBE_DEVICE } from "./actions"; import { DType } from "../types/dtypes"; function connectionChanged( store: MiddlewareAPI, - pvName: string, + pvDevice: string, + type: string, value: ConnectionState ): void { store.dispatch({ type: CONNECTION_CHANGED, - payload: { pvName: pvName, value: value } + payload: { pvDevice, value, type } }); } @@ -29,19 +38,33 @@ function valueChanged( ): void { store.dispatch({ type: VALUE_CHANGED, - payload: { pvName: pvName, value: value } + payload: { pvName, value } + }); +} + +function deviceChanged( + store: MiddlewareAPI, + device: string, + value: DType +): void { + store.dispatch({ + type: DEVICE_CHANGED, + payload: { device, value } }); } -export const connectionMiddleware = (connection: Connection) => ( +export const connectionMiddleware = (connection: ConnectionTypes) => ( store: MiddlewareAPI ) => (next: Dispatch): any => (action: Action): Action => { if (!connection.isConnected()) { connection.connect( // Partial function application. - (pvName: string, value: ConnectionState): void => - connectionChanged(store, pvName, value), - (pvName: string, value: DType): void => valueChanged(store, pvName, value) + (pvDevice: string, type: string, value: ConnectionState): void => + connectionChanged(store, pvDevice, type, value), + (pvName: string, value: DType): void => + valueChanged(store, pvName, value), + (device: string, value: DType): void => + deviceChanged(store, device, value) ); } @@ -69,12 +92,44 @@ export const connectionMiddleware = (connection: Connection) => ( }; break; } + case SUBSCRIBE_DEVICE: { + const { device } = (action as SubscribeDevice).payload; + + try { + connection.subscribe(device, { string: true }); + } catch (error) { + log.error(`Failed to subscribe to device ${device}`); + log.error(error); + } + + action = { + ...action, + payload: { + ...action.payload + } + }; + break; + } + case UNSUBSCRIBE_DEVICE: { + const { componentId, device } = action.payload; + const subs = store.getState().deviceSubscriptions; + + if (subs[device].length === 1 && subs[device][0] === componentId) { + try { + connection.unsubscribe(device); + } catch (error) { + log.error(`Failed to unsubscribe from device ${device}`); + log.error(error); + } + } + break; + } case WRITE_PV: { const { pvName, value } = action.payload; const effectivePvName = store.getState().effectivePvNameMap[pvName] || pvName; try { - connection.putPv(effectivePvName, value); + (connection as PvConnection).putPv(effectivePvName, value); } catch (error) { log.error(`Failed to put to pv ${pvName}`); log.error(error); diff --git a/src/redux/csState.test.ts b/src/redux/csState.test.ts index e9933aaf..06c37ade 100644 --- a/src/redux/csState.test.ts +++ b/src/redux/csState.test.ts @@ -9,9 +9,13 @@ import { Unsubscribe, ValueChanged, ValuesChanged, - VALUES_CHANGED + VALUES_CHANGED, + DeviceChanged, + DEVICE_CHANGED, + DevicesChanged, + DEVICES_CHANGED } from "./actions"; -import { DAlarm } from "../types/dtypes"; +import { DAlarm, DType } from "../types/dtypes"; import { ddouble, dstring, ddoubleArray } from "../setupTests"; const initialState: CsState = { @@ -23,8 +27,10 @@ const initialState: CsState = { initializingPvName: "" } }, + deviceCache: {}, globalMacros: {}, subscriptions: {}, + deviceSubscriptions: {}, effectivePvNameMap: {} }; @@ -94,15 +100,73 @@ describe("VALUE_CHANGED", (): void => { }); }); +describe("DEVICE_CHANGED", (): void => { + test("csReducer handles DEVICE_CHANGED", (): void => { + const value = new DType({ stringValue: "6" }); + const action: DeviceChanged = { + type: DEVICE_CHANGED, + payload: { + device: "new device", + componentId: "5", + value: value + } + }; + const newState = csReducer(initialState, action); + expect(newState.deviceCache["new device"].value).toEqual(value); + }); + + test("csReducer handles DEVICES_CHANGED", (): void => { + const value1 = new DType({ stringValue: "1" }); + const value2 = new DType({ stringValue: "2" }); + const payload1 = { + device: "device1", + componentId: "1", + value: value1 + }; + const payload2 = { + device: "device2", + componentId: "2", + value: value2 + }; + const devicesChanged: DevicesChanged = { + type: DEVICES_CHANGED, + payload: [ + { type: DEVICE_CHANGED, payload: payload1 }, + { type: DEVICE_CHANGED, payload: payload2 } + ] + }; + const newState = csReducer(initialState, devicesChanged); + expect(newState.deviceCache["device1"].value).toEqual(value1); + expect(newState.deviceCache["device2"].value).toEqual(value2); + }); +}); + describe("CONNECTION_CHANGED", (): void => { test("csReducer handles value update", (): void => { const action: ConnectionChanged = { type: CONNECTION_CHANGED, - payload: { pvName: "PV", value: { isConnected: false, isReadonly: true } } + payload: { + pvDevice: "PV", + type: "pv", + value: { isConnected: false, isReadonly: true } + } }; const newState = csReducer(initialState, action); expect(newState.valueCache["PV"].connected).toEqual(false); }); + + test("csReducer handles device update", (): void => { + const action: ConnectionChanged = { + type: CONNECTION_CHANGED, + payload: { + pvDevice: "device", + type: "device", + value: { isConnected: false, isReadonly: true } + } + }; + const newState = csReducer(initialState, action); + expect(newState.deviceCache["device"].connected).toEqual(false); + }); }); test("handles initializers", (): void => { diff --git a/src/redux/csState.ts b/src/redux/csState.ts index f0a92fc9..a565bf6f 100644 --- a/src/redux/csState.ts +++ b/src/redux/csState.ts @@ -4,10 +4,15 @@ import { VALUES_CHANGED, Action, SUBSCRIBE, + SUBSCRIBE_DEVICE, WRITE_PV, CONNECTION_CHANGED, UNSUBSCRIBE, - ValueChanged + ValueChanged, + UNSUBSCRIBE_DEVICE, + DEVICE_CHANGED, + DEVICES_CHANGED, + DeviceChanged } from "./actions"; import { MacroMap } from "../types/macros"; import { DType, mergeDType } from "../types/dtypes"; @@ -16,7 +21,9 @@ const initialState: CsState = { valueCache: {}, globalMacros: { SUFFIX: "1" }, effectivePvNameMap: {}, - subscriptions: {} + subscriptions: {}, + deviceSubscriptions: {}, + deviceCache: {} }; export interface PvState { @@ -33,6 +40,15 @@ export interface ValueCache { [key: string]: FullPvState; } +export interface FullDeviceState extends PvState { + [index: string]: any; + device: string; +} + +export interface DeviceCache { + [key: string]: FullDeviceState; +} + export interface Subscriptions { [pv: string]: string[]; } @@ -43,8 +59,17 @@ export interface CsState { effectivePvNameMap: { [pvName: string]: string }; globalMacros: MacroMap; subscriptions: Subscriptions; + deviceSubscriptions: Subscriptions; + deviceCache: DeviceCache; } +/** + * Merges new value of a pv with the old + * pv metadata + * @param oldValueCache + * @param newValueCache + * @param action + */ function updateValueCache( oldValueCache: ValueCache, newValueCache: ValueCache, @@ -59,6 +84,18 @@ function updateValueCache( newValueCache[action.payload.pvName] = newPvState; } +function updateDeviceCache( + oldDeviceCache: DeviceCache, + newDeviceCache: DeviceCache, + action: DeviceChanged +): void { + newDeviceCache[action.payload.device] = { + ...action.payload, + connected: true, + readonly: false + }; +} + export function csReducer(state = initialState, action: Action): CsState { log.debug(action); switch (action.type) { @@ -67,6 +104,18 @@ export function csReducer(state = initialState, action: Action): CsState { updateValueCache(state.valueCache, newValueCache, action); return { ...state, valueCache: newValueCache }; } + case DEVICE_CHANGED: { + const newDeviceCache: DeviceCache = { ...state.deviceCache }; + updateDeviceCache(state.deviceCache, newDeviceCache, action); + return { ...state, deviceCache: newDeviceCache }; + } + case DEVICES_CHANGED: { + const newDeviceCache: DeviceCache = { ...state.deviceCache }; + for (const changedAction of action.payload) { + updateDeviceCache(state.deviceCache, newDeviceCache, changedAction); + } + return { ...state, deviceCache: newDeviceCache }; + } case VALUES_CHANGED: { const newValueCache: ValueCache = { ...state.valueCache }; for (const changedAction of action.payload) { @@ -75,16 +124,32 @@ export function csReducer(state = initialState, action: Action): CsState { return { ...state, valueCache: newValueCache }; } case CONNECTION_CHANGED: { - const newValueCache: ValueCache = { ...state.valueCache }; - const { pvName, value } = action.payload; - const pvState = state.valueCache[pvName]; - const newPvState = { - ...pvState, - connected: value.isConnected, - readonly: value.isReadonly - }; - newValueCache[action.payload.pvName] = newPvState; - return { ...state, valueCache: newValueCache }; + const { pvDevice, type, value } = action.payload; + + let cache; + if (type === "pv") { + cache = state.valueCache; + const oldState = cache[pvDevice]; + const newState = { + ...oldState, + connected: value.isConnected, + readonly: value.isReadonly + }; + cache[pvDevice] = newState; + return { ...state, valueCache: cache as ValueCache }; + } else if (type === "device") { + cache = state.deviceCache; + const oldState = cache[pvDevice]; + const newState = { + ...oldState, + connected: value.isConnected, + readonly: value.isReadonly + }; + cache[pvDevice] = newState; + return { ...state, deviceCache: cache }; + } else { + break; + } } case SUBSCRIBE: { const { componentId, effectivePvName } = action.payload; @@ -139,6 +204,37 @@ export function csReducer(state = initialState, action: Action): CsState { // Handled by middleware. break; } + case SUBSCRIBE_DEVICE: { + const { device, componentId } = action.payload; + const newDeviceSubscriptions = { ...state.deviceSubscriptions }; + if (newDeviceSubscriptions.hasOwnProperty(device)) { + newDeviceSubscriptions[device].push(componentId); + } else { + newDeviceSubscriptions[device] = [componentId]; + } + return { + ...state, + deviceSubscriptions: newDeviceSubscriptions + }; + } + case UNSUBSCRIBE_DEVICE: { + const { device, componentId } = action.payload; + + const newDeviceSubscriptions = { ...state.deviceSubscriptions }; + const newDeviceSubs = state.deviceSubscriptions[device].filter( + (id): boolean => id !== componentId + ); + if (newDeviceSubs.length > 0) { + newDeviceSubscriptions[device] = newDeviceSubs; + } else { + delete newDeviceSubscriptions[device]; + } + + return { + ...state, + deviceSubscriptions: newDeviceSubscriptions + }; + } } return state; } diff --git a/src/redux/store.ts b/src/redux/store.ts index bab8b233..7cade465 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,11 +1,10 @@ import { createStore, applyMiddleware, compose } from "redux"; - +import { Connection } from "../connection/plugin"; import { csReducer } from "./csState"; import { connectionMiddleware } from "./connectionMiddleware"; import { throttleMiddleware, UpdateThrottle } from "./throttleMiddleware"; -import { Connection } from "../connection/plugin"; import { SimulatorPlugin } from "../connection/sim"; -import { ConiqlPlugin } from "../connection/coniql"; +import { ConiqlPvPlugin, ConiqlDevicePlugin } from "../connection/coniql"; import { ConnectionForwarder } from "../connection/forwarder"; const CONIQL_SOCKET = process.env.REACT_APP_CONIQL_SOCKET; @@ -16,16 +15,20 @@ const THROTTLE_PERIOD = parseFloat( const SIMULATION_TIME = parseFloat( process.env.REACT_APP_SIMULATION_TIME ?? "100" ); + const simulator = new SimulatorPlugin(SIMULATION_TIME); const plugins: [string, Connection][] = [ ["sim://", simulator], ["loc://", simulator] ]; + if (CONIQL_SOCKET !== undefined) { - const coniql = new ConiqlPlugin(CONIQL_SOCKET); - plugins.unshift(["pva://", coniql]); - plugins.unshift(["ca://", coniql]); - plugins.unshift(["ssim://", coniql]); + const coniqlPv = new ConiqlPvPlugin(CONIQL_SOCKET); + plugins.unshift(["pva://", coniqlPv]); + plugins.unshift(["ca://", coniqlPv]); + plugins.unshift(["ssim://", coniqlPv]); + const coniqlDevice = new ConiqlDevicePlugin(CONIQL_SOCKET); + plugins.unshift(["device://", coniqlDevice]); } const connection = new ConnectionForwarder(plugins); @@ -34,10 +37,11 @@ const composeEnhancers = export const store = createStore( csReducer, - /* preloadedState, */ composeEnhancers( + composeEnhancers( applyMiddleware( connectionMiddleware(connection), throttleMiddleware(new UpdateThrottle(THROTTLE_PERIOD)) ) + // other store enhancers here ) ); diff --git a/src/redux/throttleMiddleware.test.ts b/src/redux/throttleMiddleware.test.ts index 8adce6ce..0f51ce26 100644 --- a/src/redux/throttleMiddleware.test.ts +++ b/src/redux/throttleMiddleware.test.ts @@ -3,10 +3,14 @@ import { VALUE_CHANGED, CONNECTION_CHANGED, VALUES_CHANGED, + DEVICE_CHANGED, + DEVICES_CHANGED, ValueChanged, - ConnectionChanged + ConnectionChanged, + Action } from "./actions"; import { ddouble } from "../setupTests"; +import { DType } from "../types/dtypes"; // Mock setInterval. jest.useFakeTimers(); @@ -17,23 +21,52 @@ describe("UpdateThrottle", (): void => { mockStore.dispatch.mockClear(); }); it("collects updates", (): void => { - const updater = new UpdateThrottle(100); - updater.queueUpdate({ + const updater = new UpdateThrottle(10); + updater.queueUpdate( + { + type: VALUE_CHANGED, + payload: { + pvName: "PV", + value: ddouble(0) + } + }, + mockStore + ); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + }); + + it("VALUE_CHANGED queues and is dispatched", (): void => { + const updater = new UpdateThrottle(10); + const query: Action = { type: VALUE_CHANGED, payload: { pvName: "PV", value: ddouble(0) } + }; + updater.queueUpdate(query, mockStore); + expect(mockStore.dispatch).toHaveBeenCalledWith({ + type: VALUES_CHANGED, + payload: [query] }); - updater.queueUpdate({ - type: VALUE_CHANGED, + }); + + it("DEVICE_CHANGED queues and is dispatched", (): void => { + const updater = new UpdateThrottle(10); + const query: Action = { + type: DEVICE_CHANGED, payload: { - pvName: "PV", - value: ddouble(1) + device: "PV", + componentId: "device", + value: new DType({ stringValue: "15" }) } + }; + updater.queueUpdate(query, mockStore); + + expect(mockStore.dispatch).toHaveBeenCalledWith({ + type: DEVICES_CHANGED, + payload: [query] }); - updater.clearQueue(mockStore); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); }); @@ -68,7 +101,11 @@ describe("throttleMidddlware", (): void => { const actionHandler = nextHandler(mockNext); const connectionAction: ConnectionChanged = { type: CONNECTION_CHANGED, - payload: { pvName: "pv", value: { isReadonly: false, isConnected: true } } + payload: { + pvDevice: "pv", + type: "pv", + value: { isReadonly: false, isConnected: true } + } }; actionHandler(connectionAction); expect(mockStore.dispatch).toHaveBeenCalledTimes(0); diff --git a/src/redux/throttleMiddleware.ts b/src/redux/throttleMiddleware.ts index 10126a39..d83dd87f 100644 --- a/src/redux/throttleMiddleware.ts +++ b/src/redux/throttleMiddleware.ts @@ -1,36 +1,48 @@ -import { VALUE_CHANGED, Action, VALUES_CHANGED } from "./actions"; +import { + VALUE_CHANGED, + Action, + VALUES_CHANGED, + DEVICE_CHANGED, + DEVICES_CHANGED +} from "./actions"; import { MiddlewareAPI, Dispatch } from "redux"; +/** + * Throttling helper class, lets a queue build up and then sends it + * at set intervals + * @param updateMillis: the interval to flush the queue at (in milliseconds) + */ export class UpdateThrottle { - private queue: Action[]; - private started: boolean; - private updateMillis: number; + private pvQueue: Action[]; + private deviceQueue: Action[]; public ready: boolean; constructor(updateMillis: number) { - this.queue = []; - this.started = false; - this.updateMillis = updateMillis; + this.pvQueue = []; + this.deviceQueue = []; this.ready = true; - this.queueUpdate = this.queueUpdate.bind(this); - this.clearQueue = this.clearQueue.bind(this); - this.setReady = this.setReady.bind(this); + setInterval(() => (this.ready = true), updateMillis); } - public queueUpdate(action: Action): void { - this.queue.push(action); - if (!this.started) { - setInterval(this.setReady, this.updateMillis); - this.started = true; + public queueUpdate(action: Action, store: MiddlewareAPI): void { + if (action.type === VALUE_CHANGED) { + this.pvQueue.push(action); + } else if (action.type === DEVICE_CHANGED) { + this.deviceQueue.push(action); + } + if (this.ready) { + this.sendQueue(store); } } - public setReady(): void { - this.ready = true; - } - - public clearQueue(store: MiddlewareAPI): void { - store.dispatch({ type: VALUES_CHANGED, payload: [...this.queue] }); - this.queue.length = 0; + public sendQueue(store: MiddlewareAPI): void { + if (this.pvQueue.length > 0) { + store.dispatch({ type: VALUES_CHANGED, payload: [...this.pvQueue] }); + this.pvQueue = []; + } + if (this.deviceQueue.length > 0) { + store.dispatch({ type: DEVICES_CHANGED, payload: [...this.deviceQueue] }); + this.deviceQueue = []; + } this.ready = false; } } @@ -41,11 +53,8 @@ export const throttleMiddleware = (updater: UpdateThrottle) => ( // we don't call next(action) so return undefined. // this makes the return value 'Action | undefined' ) => (next: Dispatch) => (action: Action): Action | undefined => { - if (action.type === VALUE_CHANGED) { - updater.queueUpdate(action); - if (updater.ready) { - updater.clearQueue(store); - } + if (action.type === VALUE_CHANGED || action.type === DEVICE_CHANGED) { + updater.queueUpdate(action, store); } else { return next(action); } diff --git a/src/setupTests.tsx b/src/setupTests.tsx index 2cba0803..e3db80af 100644 --- a/src/setupTests.tsx +++ b/src/setupTests.tsx @@ -58,7 +58,9 @@ export function contextRender( effectivePvNameMap: {}, globalMacros: {}, subscriptions: {}, - valueCache: {} + valueCache: {}, + deviceCache: {}, + deviceSubscriptions: {} } ): RenderResult { const ParentComponent = (props: { child: JSX.Element }): JSX.Element => { diff --git a/src/ui/hooks/useConnection.test.tsx b/src/ui/hooks/useConnection.test.tsx index 787df475..bdc4f765 100644 --- a/src/ui/hooks/useConnection.test.tsx +++ b/src/ui/hooks/useConnection.test.tsx @@ -33,7 +33,9 @@ function getConnectionState(pvName: string, value: DType): CsState { }, subscriptions: { [pvName]: [] }, globalMacros: {}, - effectivePvNameMap: {} + effectivePvNameMap: {}, + deviceCache: {}, + deviceSubscriptions: {} }; } diff --git a/src/ui/hooks/useDevice.tsx b/src/ui/hooks/useDevice.tsx new file mode 100644 index 00000000..f4a5863c --- /dev/null +++ b/src/ui/hooks/useDevice.tsx @@ -0,0 +1,24 @@ +import { useSelector } from "react-redux"; +import { CsState, FullDeviceState } from "../../redux/csState"; +import { deviceSelector, deviceComparator } from "./utils"; +import { useDeviceSubscription } from "./useDeviceSubscription"; +import { DType } from "../../types/dtypes"; + +export interface Device { + connected: boolean; + device: string; + readonly: boolean; + value: DType; +} + +export function useDevice( + componentId: string, + device: string +): Device | undefined { + useDeviceSubscription(componentId, device); + const description: any = useSelector( + (state: CsState): FullDeviceState => deviceSelector(device, state), + deviceComparator + ); + return description; +} diff --git a/src/ui/hooks/useDeviceSubscription.tsx b/src/ui/hooks/useDeviceSubscription.tsx new file mode 100644 index 00000000..2736b990 --- /dev/null +++ b/src/ui/hooks/useDeviceSubscription.tsx @@ -0,0 +1,23 @@ +import { useDispatch } from "react-redux"; +import { useEffect } from "react"; +import { SUBSCRIBE_DEVICE, UNSUBSCRIBE_DEVICE } from "../../redux/actions"; + +export function useDeviceSubscription( + componentId: string, + device: string +): void { + const dispatch = useDispatch(); + + useEffect((): (() => void) => { + dispatch({ + type: SUBSCRIBE_DEVICE, + payload: { device: device, componentId } + }); + return (): void => { + dispatch({ + type: UNSUBSCRIBE_DEVICE, + payload: { device: device, componentId } + }); + }; + }, [dispatch, componentId, device]); +} diff --git a/src/ui/hooks/useRules.test.tsx b/src/ui/hooks/useRules.test.tsx index b794246d..9919cdcc 100644 --- a/src/ui/hooks/useRules.test.tsx +++ b/src/ui/hooks/useRules.test.tsx @@ -27,7 +27,9 @@ function getCsState(value: DType): CsState { }, subscriptions: { "ca://PV1": [] }, globalMacros: {}, - effectivePvNameMap: {} + effectivePvNameMap: {}, + deviceCache: {}, + deviceSubscriptions: {} }; } diff --git a/src/ui/hooks/utils.test.ts b/src/ui/hooks/utils.test.ts index f07298f7..71b1748e 100644 --- a/src/ui/hooks/utils.test.ts +++ b/src/ui/hooks/utils.test.ts @@ -1,5 +1,16 @@ -import { CsState, PvState, FullPvState } from "../../redux/csState"; -import { pvStateSelector, pvStateComparator, PvArrayResults } from "./utils"; +import { + CsState, + PvState, + FullPvState, + FullDeviceState +} from "../../redux/csState"; +import { + pvStateSelector, + pvStateComparator, + PvArrayResults, + deviceSelector, + deviceComparator +} from "./utils"; const pv1 = "pv1"; const pv2 = "pv2"; @@ -10,12 +21,20 @@ const pvState: FullPvState = { readonly: true, initializingPvName: pv1 }; +const deviceState: FullDeviceState = { + value: undefined, + connected: true, + readonly: true, + device: "fakeDevice" +}; const state: CsState = { valueCache: { pv1: pvState }, globalMacros: {}, effectivePvNameMap: { pv1: "pv1", pv2: "pv3" }, - subscriptions: {} + subscriptions: {}, + deviceCache: { fakeDevice: deviceState }, + deviceSubscriptions: {} }; describe("pvStateSelector", (): void => { @@ -69,3 +88,38 @@ describe("pvStateComparator", (): void => { expect(pvStateComparator(singleResult, anotherSingleResult)).toBe(false); }); }); + +describe("deviceSelector", (): void => { + it("returns appropriate device value if available", () => { + const result = deviceSelector("fakeDevice", state); + expect(result.device).toEqual("fakeDevice"); + }); + + it("returns undefined if a device is not available", (): void => { + const result = deviceSelector("anotherFakeDevice", state); + expect(result).toBeUndefined(); + }); +}); + +describe("deviceComparator", (): void => { + it("returns true if the same object is passed twice", (): void => { + expect(deviceComparator(deviceState, deviceState)).toBe(true); + }); + + it("returns true if one is a copy of the other", (): void => { + const newDevice: FullDeviceState = { ...deviceState }; + expect(deviceComparator(deviceState, newDevice)).toBe(true); + }); + + it("returns false if both match but one has an extra property", (): void => { + const newDevice = { ...deviceState }; + newDevice.newValue = "5"; + expect(deviceComparator(deviceState, newDevice)).toBe(false); + }); + + it("returns false one property doesn't match", (): void => { + const newDevice = { ...deviceState }; + newDevice.connected = !newDevice.connected; + expect(deviceComparator(deviceState, newDevice)).toBe(false); + }); +}); diff --git a/src/ui/hooks/utils.ts b/src/ui/hooks/utils.ts index fe371050..8b141d25 100644 --- a/src/ui/hooks/utils.ts +++ b/src/ui/hooks/utils.ts @@ -1,4 +1,4 @@ -import { PvState, CsState } from "../../redux/csState"; +import { PvState, CsState, FullDeviceState } from "../../redux/csState"; export interface PvArrayResults { [pvName: string]: [PvState, string]; @@ -16,6 +16,13 @@ export function pvStateSelector( return results; } +export function deviceSelector( + device: string, + state: CsState +): FullDeviceState { + return state.deviceCache[device]; +} + /* Used for preventing re-rendering if the results are equivalent. Note that if the state for a particular PV hasn't changed, we will get back the same object as last time so we are safe to compare them. @@ -40,3 +47,20 @@ export function pvStateComparator( } return true; } + +export function deviceComparator( + before: FullDeviceState, + after: FullDeviceState +): boolean { + if (Object.keys(before).length !== Object.keys(after).length) { + return false; + } + + for (const [property, beforeValue] of Object.entries(before)) { + const afterValue = after[property]; + if (afterValue !== beforeValue) { + return false; + } + } + return true; +} diff --git a/src/ui/widgets/Device/device.tsx b/src/ui/widgets/Device/device.tsx new file mode 100644 index 00000000..6ba48344 --- /dev/null +++ b/src/ui/widgets/Device/device.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Widget } from "./../widget"; +import { WidgetPropType } from "./../widgetProps"; +import { InferWidgetProps, StringPropOpt, StringProp } from "./../propTypes"; +import { registerWidget } from "./../register"; +import { useDevice } from "../../hooks/useDevice"; + +const DeviceProps = { + deviceName: StringProp, + id: StringPropOpt +}; + +const DeviceComponent = ( + props: InferWidgetProps +): JSX.Element => { + // let components = ""; + const description = useDevice(props.id || "", props.deviceName); + // const components = coniqlToJSON(device); + + // const description = parseJson(components, "pva"); + + // const component = widgetDescriptionToComponent({ + // position: new RelativePosition("100%", "100%"), + // type: "display", + // children: [description] + // }); + return
{(description && description.value?.toString()) || ""}
; +}; + +const DeviceWidgetProps = { + ...DeviceProps, + ...WidgetPropType +}; + +export const Device = ( + props: InferWidgetProps +): JSX.Element => ; + +registerWidget(Device, DeviceWidgetProps, "device"); diff --git a/src/ui/widgets/EmbeddedDisplay/opiParser.ts b/src/ui/widgets/EmbeddedDisplay/opiParser.ts index ba594b4e..c11ab520 100644 --- a/src/ui/widgets/EmbeddedDisplay/opiParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/opiParser.ts @@ -55,7 +55,8 @@ const OPI_WIDGET_MAPPING: { [key: string]: any } = { "org.csstudio.opibuilder.widgets.polyline": "line", "org.csstudio.opibuilder.widgets.symbol.multistate.MultistateMonitorWidget": "symbol", - "org.csstudio.opibuilder.widgets.LED": "led" + "org.csstudio.opibuilder.widgets.LED": "led", + "org.csstudio.opibuilder.widgets.detailpanel": "device" }; /** @@ -204,7 +205,6 @@ export function opiParseActions( (action.description && action.description._text) || undefined, file: { path: action.path._text, - // TODO: Should probably be accessing properties of the element here macros: {}, defaultProtocol: "pva" } diff --git a/src/ui/widgets/index.ts b/src/ui/widgets/index.ts index d8d50a38..69d8facf 100644 --- a/src/ui/widgets/index.ts +++ b/src/ui/widgets/index.ts @@ -2,6 +2,7 @@ import log from "loglevel"; export { ActionButton } from "./ActionButton/actionButton"; export { Checkbox } from "./Checkbox/checkbox"; +export { Device } from "./Device/device"; export { Display } from "./Display/display"; export { DrawerWidget } from "./Drawer/drawer"; export { DropDown } from "./DropDown/dropDown"; diff --git a/src/ui/widgets/widget.test.tsx b/src/ui/widgets/widget.test.tsx index 086ca229..8dc12bd3 100644 --- a/src/ui/widgets/widget.test.tsx +++ b/src/ui/widgets/widget.test.tsx @@ -37,7 +37,9 @@ describe("", (): void => { effectivePvNameMap: {}, globalMacros: {}, subscriptions: { [PV_NAME]: [] }, - valueCache: {} + valueCache: {}, + deviceCache: {}, + deviceSubscriptions: {} }; const { getByText } = contextRender(