node-red-contrib-room-tools 1.8.16 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/control-panel.js CHANGED
@@ -11,6 +11,7 @@ const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visu
11
11
  const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
13
  const { createMeterRoom, createPanelMeter } = require('./panel-meter')
14
+ const { createXYSeriesRoom, createPanelXYSeries } = require('./panel-xy-series')
14
15
 
15
16
  let statestore
16
17
  const authHandoffKey = crypto.randomBytes(32)
@@ -71,6 +72,7 @@ module.exports = function (RED) {
71
72
  const activePanels = new Map()
72
73
  const activeEditors = new Map()
73
74
  const meterRooms = new Map()
75
+ const xySeriesRooms = new Map()
74
76
  RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
75
77
  res.setHeader('Cache-Control', 'no-store')
76
78
  res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
@@ -159,6 +161,16 @@ module.exports = function (RED) {
159
161
  } catch (error) { node.error(error) }
160
162
  }
161
163
 
164
+ let xySeries
165
+ if ([...routes.values()].some(route => route.binding.readMeta?.xySeries) && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
166
+ try {
167
+ let hub = xySeriesRooms.get(boundRoom)
168
+ if (!hub) { hub = createXYSeriesRoom({ room: boundRoom }); xySeriesRooms.set(boundRoom, hub) }
169
+ xySeries = createPanelXYSeries({ panelId: node.id, room: boundRoom, bindings: visualBindings, hub,
170
+ onSubscribe: () => { if (bridgeReady) node.send({ kind: 'read-all', room: boundRoom }) } })
171
+ } catch (error) { node.error(error) }
172
+ }
173
+
162
174
  let gateway
163
175
  let gatewayAuthKey
164
176
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
@@ -173,7 +185,8 @@ module.exports = function (RED) {
173
185
  secureCookie: config.secureCookie === true,
174
186
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
175
187
  onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
176
- () => gateway?.sessionActive(identity.token)) || false,
188
+ () => gateway?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
189
+ () => gateway?.sessionActive(identity.token)) || false,
177
190
  onBoundCommand: (message, identity) => {
178
191
  if (!bridgeReady) return false
179
192
  node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
@@ -203,7 +216,8 @@ module.exports = function (RED) {
203
216
  secureCookie: config.secureCookie === true,
204
217
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
205
218
  onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
206
- () => editor?.sessionActive(identity.token)) || false,
219
+ () => editor?.sessionActive(identity.token)) || xySeries?.ticket(req, res, identity,
220
+ () => editor?.sessionActive(identity.token)) || false,
207
221
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
208
222
  RED.settings.httpAdminRoot ?? '/'), node.id),
209
223
  getDevices: async () => {
@@ -230,7 +244,7 @@ module.exports = function (RED) {
230
244
  }
231
245
 
232
246
  const panelConfig = () => (boundMode || visualBinding) ? {
233
- endpoint, room: boundRoom, meter: Boolean(meter),
247
+ endpoint, room: boundRoom, meter: Boolean(meter), xySeries: Boolean(xySeries),
234
248
  ...(visualBinding ? { visual: {
235
249
  panelId: node.id,
236
250
  editorEndpoint: `/control-panel-edit/panels/${node.id}`,
@@ -241,7 +255,7 @@ module.exports = function (RED) {
241
255
 
242
256
  const events = {
243
257
  onAction: !boundMode,
244
- ...(meter ? { onSocket: meter.hooks } : {}),
258
+ ...((meter || xySeries) ? { onSocket: { ...(meter?.hooks || {}), ...(xySeries?.hooks || {}) } } : {}),
245
259
  beforeSend (msg) {
246
260
  if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
247
261
  if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
@@ -250,6 +264,10 @@ module.exports = function (RED) {
250
264
  // should be emitted. `false` is property-safe and suppresses emit.
251
265
  return false
252
266
  }
267
+ if (['device-xy-series-frame', 'device-xy-series-manifest'].includes(msg?.kind)) {
268
+ if (bridgeReady) xySeries?.accept(msg)
269
+ return false
270
+ }
253
271
  if (boundMode) {
254
272
  if (msg?.kind === 'device-feedback' && gateway) {
255
273
  gateway.boundFeedback(resolveBoundFeedback(routes, msg))
@@ -325,7 +343,9 @@ module.exports = function (RED) {
325
343
  gateway?.close()
326
344
  editor?.close()
327
345
  meter?.close()
346
+ xySeries?.close()
328
347
  if (meterRooms.get(boundRoom)?.stats().panels === 0) meterRooms.delete(boundRoom)
348
+ if (xySeriesRooms.get(boundRoom)?.stats().panels === 0) xySeriesRooms.delete(boundRoom)
329
349
  if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
330
350
  if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
331
351
  })
package/control-policy.js CHANGED
@@ -5,6 +5,8 @@ const { fetchWithTimeout } = require('./panel-http')
5
5
  const { createTargetMatcher } = require('./control-type-model')
6
6
  const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
7
7
  const { createPanelMeterClient } = require('./panel-meter-client')
8
+ const { createRoomXYSeriesReceiver, createXYSeriesRenderer } = require('./xy-series-frame')
9
+ const { createPanelXYSeriesClient } = require('./panel-xy-series-client')
8
10
 
9
11
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
10
12
 
@@ -164,6 +166,11 @@ function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
164
166
  __createMeterRenderer: ${createMeterRenderer.toString()},
165
167
  __createPanelMeterClient: ${createPanelMeterClient.toString()},`
166
168
  : ''
169
+ const xySeriesMethods = boundPanel?.xySeries
170
+ ? `__createXYSeriesReceiver: ${createRoomXYSeriesReceiver.toString()},
171
+ __createXYSeriesRenderer: ${createXYSeriesRenderer.toString()},
172
+ __createPanelXYSeriesClient: ${createPanelXYSeriesClient.toString()},`
173
+ : ''
167
174
  const visualMethods = boundPanel?.visual
168
175
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
169
176
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
@@ -188,6 +195,7 @@ export default {
188
195
  },
189
196
  methods: {
190
197
  ${meterMethods}
198
+ ${xySeriesMethods}
191
199
  ${visualMethods}
192
200
  __panelFetch: ${fetchWithTimeout.toString()},
193
201
  __boundPanelConfig () {
@@ -5,6 +5,7 @@
5
5
  "level": { "family": "number", "delivery": "continuous" },
6
6
  "volume": { "family": "number", "delivery": "continuous" },
7
7
  "meter": { "family": "number", "delivery": "realtime", "access": "ro" },
8
+ "xy-series": { "family": "xy-series", "delivery": "realtime", "access": "ro" },
8
9
  "dimmer": { "family": "number", "delivery": "continuous" },
9
10
  "position": { "family": "number", "delivery": "continuous" },
10
11
  "toggle": { "family": "boolean", "delivery": "discrete" },
@@ -6,10 +6,11 @@ const { meterRoute } = require('./meter-route')
6
6
  // Used by both server validation and the injected editor; keep it self-contained.
7
7
  function createTargetMatcher (typeContract) {
8
8
  const families = {
9
- range: ['number'], switch: ['boolean'], meter: ['number'],
9
+ range: ['number'], switch: ['boolean'], meter: ['number'], 'xy-series': ['xy-series'],
10
10
  'text-display': ['text', 'number', 'enum', 'boolean'], 'text-input': ['text'], select: ['enum']
11
11
  }
12
12
  function family (target) {
13
+ if (target.xySeries) return 'xy-series'
13
14
  if (target.controlSchemaVersion !== typeContract.schemaVersion) return null
14
15
  const definition = typeContract.types[String(target.controlType || '').toLowerCase()]
15
16
  if (!definition) return null
@@ -24,9 +25,11 @@ function createTargetMatcher (typeContract) {
24
25
  }
25
26
  function compatible (adapter, target, direction, candidate = {}) {
26
27
  if (adapter === 'meter' && !target.meter) return false
28
+ if (adapter === 'xy-series' && !target.xySeries) return false
27
29
  if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
30
+ if (target.xySeries && (adapter !== 'xy-series' || direction !== 'read' || target.access !== 'ro')) return false
28
31
  if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
29
- if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
32
+ if (direction === 'write' && ['meter', 'xy-series', 'text-display'].includes(adapter)) return false
30
33
  if (!['read', 'write'].includes(direction)) return false
31
34
  if (!(direction === 'write' ? ['wo', 'rw'] : ['ro', 'rw']).includes(target.access)) return false
32
35
  if (candidate.unit && candidate.unit !== target.unit) return false
@@ -42,6 +45,7 @@ function controlTypeForAdapter (adapter, target) {
42
45
  throw new Error('Target is incompatible with this control')
43
46
  }
44
47
  if (adapter === 'meter' && target.meter) return 'meter'
48
+ if (adapter === 'xy-series' && target.xySeries) return 'xy-series'
45
49
  const family = targetFamily(target)
46
50
  if (family === 'number') return 'level'
47
51
  if (family === 'boolean') return 'mute'
@@ -60,7 +64,8 @@ function targetMeta (target, onebased) {
60
64
  min: target.min, max: target.max, enums: target.enums,
61
65
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
62
66
  controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
63
- ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
67
+ ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {}),
68
+ ...(target.xySeries ? { xySeries: require('./xy-series-route').xySeriesRoute(target.xySeries) } : {})
64
69
  }
65
70
  }
66
71
 
@@ -25,7 +25,8 @@ function panelTargets (panel) {
25
25
  device: binding.device, path, kind: meta.kind, type: meta.type,
26
26
  access: meta.access, onebased: meta.onebased,
27
27
  ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
28
- ...(meta.meter ? { meter: meta.meter } : {})
28
+ ...(meta.meter ? { meter: meta.meter } : {}),
29
+ ...(meta.xySeries ? { xySeries: meta.xySeries } : {})
29
30
  }
30
31
  const key = JSON.stringify([target.device, target.path])
31
32
  const previous = targets.get(key)
@@ -1,5 +1,6 @@
1
1
  const { configuredRoutes } = require('./control-bound-protocol')
2
2
  const { meterRoute } = require('./meter-route')
3
+ const { xySeriesRoute } = require('./xy-series-route')
3
4
 
4
5
  const supportedTypes = {
5
6
  boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
@@ -30,6 +31,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
30
31
  const readTargets = new Map()
31
32
  const writeTargets = new Map()
32
33
  const meterStreams = new Map()
34
+ const xySeriesStreams = new Map()
33
35
  const commandRules = []
34
36
  const commandWires = []
35
37
 
@@ -37,7 +39,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
37
39
  const target = targetIndex.get(JSON.stringify([device, path]))
38
40
  if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
39
41
  if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
40
- if (!Object.hasOwn(supportedTypes, target.type)) {
42
+ if (!target.xySeries && !Object.hasOwn(supportedTypes, target.type)) {
41
43
  throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
42
44
  }
43
45
  if (['mute', 'power'].includes(type) && target.type !== 'boolean' &&
@@ -56,7 +58,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
56
58
  if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
57
59
  throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
58
60
  }
59
- if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'text', 'source', 'preset'].includes(type)) {
61
+ if (type === 'xy-series' && (!target.xySeries || target.access !== 'ro')) {
62
+ throw new Error(`XY-series control must bind to a readonly XY-series target: ${path}`)
63
+ }
64
+ if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'xy-series', 'text', 'source', 'preset'].includes(type)) {
60
65
  throw new Error(`Control type requires a separate command adapter: ${type}`)
61
66
  }
62
67
  if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
@@ -134,6 +139,49 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
134
139
  }
135
140
  continue
136
141
  }
142
+ if (target.xySeries) {
143
+ const series = xySeriesRoute(target.xySeries)
144
+ if (control.mode !== 'read' || control.adapter !== 'xy-series') {
145
+ throw Error(`XY-series is read-only: ${id}`)
146
+ }
147
+ const key = JSON.stringify([target.device, series.streamId])
148
+ const previous = xySeriesStreams.get(key)
149
+ if (previous) {
150
+ if (JSON.stringify(previous.series) !== JSON.stringify(series) || previous.onebased !== target.onebased) {
151
+ throw Error(`Conflicting xy-series stream: ${series.streamId}`)
152
+ }
153
+ continue
154
+ }
155
+ const index = xySeriesStreams.size + 1
156
+ const statusId = `${scopeId}-xy-series-read-${index}`
157
+ const tagIds = []
158
+ xySeriesStreams.set(key, { series, onebased: target.onebased, statusId, tagIds })
159
+ for (const [role, path] of [['manifest', series.manifestPath], ['frame', series.eventPath]]) {
160
+ const eventId = `${scopeId}-xy-series-${role}-${index}`
161
+ const tagId = `${eventId}-tag`
162
+ tagIds.push(tagId)
163
+ const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
164
+ if (role === 'manifest') nodes.push({
165
+ id: statusId, type: 'Status', z: subflowId, name: `Read xy-series manifest ${target.device}`,
166
+ x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
167
+ parameter: path, onebased: target.onebased }, wires: [[tagId]]
168
+ })
169
+ nodes.push({ id: eventId, type: 'Event', z: subflowId,
170
+ name: `Watch xy-series ${role} ${target.device} ${series.streamId}`, x: 440, y,
171
+ mojo: { version: '1.0', device: [target.device], event: path,
172
+ eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
173
+ onebased: target.onebased }, wires: [[tagId]] })
174
+ nodes.push({ id: tagId, type: 'change', z: subflowId,
175
+ name: `Identify xy-series ${role} ${target.device}`, x: 1000, y,
176
+ rules: [
177
+ { t: 'set', p: 'kind', pt: 'msg', to: 'device-xy-series-' + role, tot: 'str' },
178
+ { t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
179
+ { t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
180
+ { t: 'set', p: 'streamId', pt: 'msg', to: series.streamId, tot: 'str' }
181
+ ], wires: [[]] })
182
+ }
183
+ continue
184
+ }
137
185
  const key = JSON.stringify([target.device, target.path])
138
186
  if (readTargets.has(key)) continue
139
187
  const index = readTargets.size + 1
@@ -175,7 +223,11 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
175
223
 
176
224
  const commandSwitchId = `${scopeId}-commands`
177
225
  // Meter lanes follow ordinary feedback lanes without overlapping their labels.
178
- for (const node of nodes) if (node.id.startsWith(`${scopeId}-meter-`)) node.y += readTargets.size * 170
226
+ for (const node of nodes) {
227
+ if (node.id.startsWith(`${scopeId}-meter-`) || node.id.startsWith(`${scopeId}-xy-series-`)) {
228
+ node.y += readTargets.size * 170
229
+ }
230
+ }
179
231
  const routerId = `${scopeId}-router`
180
232
  if (commandRules.length) {
181
233
  nodes.push({
@@ -196,17 +248,18 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
196
248
  ],
197
249
  checkall: 'false', repair: false, outputs: 2,
198
250
  wires: [commandRules.length ? [commandSwitchId] : [],
199
- [...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
251
+ [...readTargets.values(), ...meterStreams.values(), ...xySeriesStreams.values()].map(target => target.statusId)]
200
252
  })
201
253
  const subflow = {
202
254
  id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
203
255
  info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
204
256
  category: 'panel binding',
205
257
  in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
206
- out: readTargets.size || meterStreams.size
258
+ out: readTargets.size || meterStreams.size || xySeriesStreams.size
207
259
  ? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
208
260
  wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
209
- .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
261
+ .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 }))))
262
+ .concat([...xySeriesStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
210
263
  : [],
211
264
  env: [], meta: {}, color: '#96bca7'
212
265
  }
@@ -215,8 +268,9 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
215
268
  manifest: {
216
269
  subflowId,
217
270
  controls: routes.size,
218
- nativeReads: readTargets.size + meterStreams.size,
271
+ nativeReads: readTargets.size + meterStreams.size + xySeriesStreams.size,
219
272
  nativeMeterStreams: meterStreams.size,
273
+ nativeXYSeriesStreams: xySeriesStreams.size,
220
274
  nativeWrites: writeTargets.size,
221
275
  requiresController: true,
222
276
  initialReadMessage: { kind: 'read-all' }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.16",
3
+ "version": "1.9.1",
4
4
  "lockfileVersion": 2,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "node-red-contrib-room-tools",
9
- "version": "1.8.16",
9
+ "version": "1.9.1",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@vue/compiler-sfc": "3.5.42",
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.16",
3
+ "version": "1.9.1",
4
4
  "description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
- "engines": { "node": ">=18.16.0" },
6
+ "engines": {
7
+ "node": ">=18.16.0"
8
+ },
7
9
  "keywords": [
8
10
  "node-red"
9
11
  ],
@@ -25,6 +27,10 @@
25
27
  "meter-frame.js",
26
28
  "panel-meter.js",
27
29
  "panel-meter-client.js",
30
+ "xy-series-route.js",
31
+ "xy-series-frame.js",
32
+ "panel-xy-series.js",
33
+ "panel-xy-series-client.js",
28
34
  "panel-template-instrumentation.js",
29
35
  "panel-visual-bindings.js",
30
36
  "panel-visual-apply.js",