node-red-contrib-room-tools 1.8.15 → 1.9.0

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-policy.js CHANGED
@@ -3,6 +3,8 @@ const escodegen = require('escodegen')
3
3
  const { initializeVisualBinding, disposeVisualBinding } = require('./panel-binding-editor')
4
4
  const { fetchWithTimeout } = require('./panel-http')
5
5
  const { createTargetMatcher } = require('./control-type-model')
6
+ const { createRoomMeterReceiver, createMeterRenderer } = require('./meter-frame')
7
+ const { createPanelMeterClient } = require('./panel-meter-client')
6
8
 
7
9
  const CONTROL_POLICY_MARKER = '/* ui-control-panel-control-api-v1 */'
8
10
 
@@ -157,6 +159,11 @@ function createControlPanelPolicy ({
157
159
  // generated script remains inside that supported subset.
158
160
  function buildDashboardPolicyOptionsSource (controls = [], boundPanel = null) {
159
161
  const boundControls = controls.map(({ id, type, mode }) => ({ id, type, mode }))
162
+ const meterMethods = boundPanel?.meter
163
+ ? `__createRoomMeterReceiver: ${createRoomMeterReceiver.toString()},
164
+ __createMeterRenderer: ${createMeterRenderer.toString()},
165
+ __createPanelMeterClient: ${createPanelMeterClient.toString()},`
166
+ : ''
160
167
  const visualMethods = boundPanel?.visual
161
168
  ? `__createTargetMatcher: ${createTargetMatcher.toString()},
162
169
  __initializeVisualBinding: ${initializeVisualBinding.toString()},
@@ -180,6 +187,7 @@ export default {
180
187
  }
181
188
  },
182
189
  methods: {
190
+ ${meterMethods}
183
191
  ${visualMethods}
184
192
  __panelFetch: ${fetchWithTimeout.toString()},
185
193
  __boundPanelConfig () {
@@ -304,6 +312,7 @@ export default {
304
312
  }
305
313
  },
306
314
  __disposeControlPanelPolicy () {
315
+ this.__boundAuthGeneration++
307
316
  this.__boundUnwatch?.()
308
317
  this.__boundUnwatch = null
309
318
  this.__clearAllBoundSettlements()
@@ -381,28 +390,28 @@ export default {
381
390
  if (!config) throw new Error('This panel has no bound login endpoint')
382
391
  this.clearBoundPanelSession()
383
392
  const generation = this.__boundAuthGeneration
384
- const response = await this.__panelFetch(config.endpoint + '/login', {
393
+ const { response, session } = await this.__panelFetch(config.endpoint + '/login', {
385
394
  method: 'POST', credentials: 'same-origin',
386
395
  headers: { 'Content-Type': 'application/json' },
387
396
  body: JSON.stringify({ username, password })
388
- })
397
+ }, 12000, async response => ({ response, session: response.ok ? await response.json() : null }))
389
398
  if (!response.ok) throw new Error('Panel login failed (' + response.status + ')')
390
- const session = await response.json()
391
399
  if (generation !== this.__boundAuthGeneration) return null
392
400
  this.setBoundPanelSession(session)
393
401
  return session
394
402
  },
395
- async restoreBoundPanelSession () {
403
+ async restoreBoundPanelSession (timeoutMs = 2500) {
396
404
  const config = this.__boundPanelConfig()
397
405
  if (!config) return null
398
406
  const generation = this.__boundAuthGeneration
399
- const response = await this.__panelFetch(config.endpoint + '/session', { credentials: 'same-origin' })
407
+ const { response, session } = await this.__panelFetch(config.endpoint + '/session', {
408
+ credentials: 'same-origin'
409
+ }, timeoutMs, async response => ({ response, session: response.ok ? await response.json() : null }))
400
410
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
401
411
  if (!response.ok) {
402
412
  this.clearBoundPanelSession()
403
413
  return null
404
414
  }
405
- const session = await response.json()
406
415
  if (generation !== this.__boundAuthGeneration) return this.__boundPanelSession
407
416
  this.setBoundPanelSession(session)
408
417
  return session
@@ -456,7 +465,7 @@ export default {
456
465
  },
457
466
  mounted () {
458
467
  this.__initializeControlPanelPolicy()
459
- if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)
468
+ ${boundPanel?.visual ? '' : 'if (this.__boundPanelConfig()) this.restoreBoundPanelSession().catch(() => undefined)'}
460
469
  ${visualMounted}
461
470
  },
462
471
  unmounted () {
@@ -4,6 +4,7 @@
4
4
  "slider": { "family": "number", "delivery": "continuous" },
5
5
  "level": { "family": "number", "delivery": "continuous" },
6
6
  "volume": { "family": "number", "delivery": "continuous" },
7
+ "meter": { "family": "number", "delivery": "realtime", "access": "ro" },
7
8
  "dimmer": { "family": "number", "delivery": "continuous" },
8
9
  "position": { "family": "number", "delivery": "continuous" },
9
10
  "toggle": { "family": "boolean", "delivery": "discrete" },
@@ -1,4 +1,5 @@
1
1
  const contract = require('./control-type-contract.json')
2
+ const { meterRoute } = require('./meter-route')
2
3
 
3
4
 
4
5
 
@@ -22,6 +23,8 @@ function createTargetMatcher (typeContract) {
22
23
  return definition.family
23
24
  }
24
25
  function compatible (adapter, target, direction, candidate = {}) {
26
+ if (adapter === 'meter' && !target.meter) return false
27
+ if (target.meter && (adapter !== 'meter' || direction !== 'read' || target.access !== 'ro')) return false
25
28
  if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
26
29
  if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
27
30
  if (!['read', 'write'].includes(direction)) return false
@@ -38,6 +41,7 @@ function controlTypeForAdapter (adapter, target) {
38
41
  if (!compatibleTarget(adapter, target, 'read') && !compatibleTarget(adapter, target, 'write')) {
39
42
  throw new Error('Target is incompatible with this control')
40
43
  }
44
+ if (adapter === 'meter' && target.meter) return 'meter'
41
45
  const family = targetFamily(target)
42
46
  if (family === 'number') return 'level'
43
47
  if (family === 'boolean') return 'mute'
@@ -55,7 +59,8 @@ function targetMeta (target, onebased) {
55
59
  onebased: typeof target.onebased === 'boolean' ? target.onebased : onebased,
56
60
  min: target.min, max: target.max, enums: target.enums,
57
61
  ...(target.booleanEncoding ? { booleanEncoding: target.booleanEncoding } : {}),
58
- controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion
62
+ controlType: target.controlType, controlSchemaVersion: target.controlSchemaVersion,
63
+ ...(target.meter ? { meter: meterRoute(target.meter), unit: target.unit || '' } : {})
59
64
  }
60
65
  }
61
66
 
package/gateway.cjs CHANGED
@@ -9,6 +9,7 @@ function createGateway ({
9
9
  room = 'room-01',
10
10
  onBoundCommand,
11
11
  onBoundLogin,
12
+ onAuthenticatedRequest,
12
13
  secureCookie = false,
13
14
  prefix,
14
15
  sessionTtlMs = 8 * 3600000,
@@ -193,6 +194,8 @@ function createGateway ({
193
194
  reply(res, 403, { error: 'CSRF rejected' })
194
195
  return true
195
196
  }
197
+ if (onAuthenticatedRequest && await onAuthenticatedRequest(req, res,
198
+ { token, expiry: session.expiry, room, user: username })) return true
196
199
  if (path === routePrefix + '/session' && req.method === 'GET') {
197
200
  reply(res, 200, { room, username, csrf: session.csrf })
198
201
  return true
@@ -255,6 +258,7 @@ function createGateway ({
255
258
  return {
256
259
  handler,
257
260
  boundFeedback,
261
+ sessionActive: token => !closed && (sessions.get(token)?.expiry || 0) > Date.now(),
258
262
  sessionSnapshot () {
259
263
  return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
260
264
  .map(([token, session]) => [token, { ...session }]))
@@ -24,7 +24,8 @@ function panelTargets (panel) {
24
24
  const target = {
25
25
  device: binding.device, path, kind: meta.kind, type: meta.type,
26
26
  access: meta.access, onebased: meta.onebased,
27
- ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {})
27
+ ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {}),
28
+ ...(meta.meter ? { meter: meta.meter } : {})
28
29
  }
29
30
  const key = JSON.stringify([target.device, target.path])
30
31
  const previous = targets.get(key)
package/meter-frame.js ADDED
@@ -0,0 +1,111 @@
1
+ // Shared, non-reactive receivers; usable in Node and injected Vue component methods.
2
+ function createMeterReceiver (manifest) {
3
+ const number = value => typeof value === 'number' && Number.isFinite(value)
4
+ if (manifest?.kind !== 'meter-manifest' || typeof manifest.streamId !== 'string' ||
5
+ !manifest.streamId.trim() || manifest.streamId.length > 255 ||
6
+ typeof manifest.epoch !== 'string' || !manifest.epoch || manifest.epoch.length > 128 ||
7
+ !Number.isInteger(manifest.layoutVersion) || manifest.layoutVersion < 1 ||
8
+ manifest.layoutVersion > 2147483647 || manifest.clock !== 'monotonic-ms' ||
9
+ !number(manifest.publishHz) || manifest.publishHz < 1 || manifest.publishHz > 60 ||
10
+ !Array.isArray(manifest.points) || !manifest.points.length || manifest.points.length > 256) {
11
+ throw Error('Invalid meter manifest')
12
+ }
13
+ const paths = new Set()
14
+ const points = Array.from(manifest.points, point => {
15
+ if (typeof point?.path !== 'string' || !/^\/[^/]/.test(point.path) ||
16
+ point.path.length > 1025 || paths.has(point.path) ||
17
+ !['integer', 'float'].includes(point.type) || !number(point.min) ||
18
+ !number(point.max) || point.min >= point.max ||
19
+ (point.type === 'integer' && (!Number.isSafeInteger(point.min) || !Number.isSafeInteger(point.max))) ||
20
+ typeof point.unit !== 'string' || point.unit.length > 32) throw Error('Invalid meter point')
21
+ paths.add(point.path)
22
+ return Object.freeze({ path: point.path, type: point.type, min: point.min, max: point.max, unit: point.unit })
23
+ })
24
+ const schema = Object.freeze({ ...manifest, points: Object.freeze(points) })
25
+ let latest = null, pending = false, closed = false
26
+ return {
27
+ manifest: schema,
28
+ accept (frame) {
29
+ if (closed || frame?.kind !== 'meter-frame' || frame.streamId !== schema.streamId ||
30
+ frame.epoch !== schema.epoch || frame.layoutVersion !== schema.layoutVersion ||
31
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
32
+ !number(frame.sampleTime) || frame.sampleTime < 0 ||
33
+ !Array.isArray(frame.values) || frame.values.length !== points.length ||
34
+ !Array.isArray(frame.valid) || frame.valid.length !== points.length) return false
35
+ const values = [], valid = []
36
+ for (let index = 0; index < points.length; index++) {
37
+ const point = points[index], value = frame.values[index], flag = frame.valid[index]
38
+ if (typeof flag !== 'boolean' || (flag && (!number(value) ||
39
+ value < point.min || value > point.max ||
40
+ (point.type === 'integer' && !Number.isSafeInteger(value))))) return false
41
+ values.push(flag ? value : null)
42
+ valid.push(flag)
43
+ }
44
+ latest = Object.freeze({ kind: 'meter-frame', streamId: schema.streamId,
45
+ epoch: schema.epoch, seq: frame.seq, layoutVersion: schema.layoutVersion,
46
+ sampleTime: frame.sampleTime, values: Object.freeze(values), valid: Object.freeze(valid) })
47
+ pending = true
48
+ return true
49
+ },
50
+ snapshot () { return latest },
51
+ take () {
52
+ if (!pending) return null
53
+ pending = false
54
+ return latest
55
+ },
56
+ close () { closed = true; pending = false; latest = null }
57
+ }
58
+ }
59
+
60
+ function createMeterRenderer ({ receiver, requestFrame, cancelFrame, apply }) {
61
+ let handle = null, closed = false
62
+ return {
63
+ receive (frame) {
64
+ if (closed || !receiver.accept(frame)) return false
65
+ if (handle === null) {
66
+ handle = requestFrame(() => {
67
+ handle = null
68
+ const current = receiver.take()
69
+ if (!closed && current) apply(current)
70
+ })
71
+ }
72
+ return true
73
+ },
74
+ close () {
75
+ closed = true
76
+ if (handle !== null) cancelFrame(handle)
77
+ handle = null
78
+ receiver.close()
79
+ }
80
+ }
81
+ }
82
+
83
+ function createRoomMeterReceiver (layout) {
84
+ if (layout?.kind !== 'room-meter-layout' || typeof layout.panelId !== 'string' ||
85
+ typeof layout.room !== 'string' || typeof layout.epoch !== 'string' || !layout.epoch ||
86
+ !Array.isArray(layout.ids) || !layout.ids.length || layout.ids.length > 256 ||
87
+ layout.ids.some(id => typeof id !== 'string' || !id) || new Set(layout.ids).size !== layout.ids.length) {
88
+ throw Error('Invalid Room meter layout')
89
+ }
90
+ let latest = null, pending = false, closed = false
91
+ const count = layout.ids.length
92
+ return {
93
+ accept (frame) {
94
+ if (closed || frame?.kind !== 'room-meter-frame' || frame.panelId !== layout.panelId ||
95
+ frame.room !== layout.room || frame.epoch !== layout.epoch ||
96
+ !Number.isSafeInteger(frame.seq) || frame.seq < 1 || (latest && frame.seq <= latest.seq) ||
97
+ !Array.isArray(frame.values) || frame.values.length !== count ||
98
+ !Array.isArray(frame.valid) || frame.valid.length !== count ||
99
+ frame.valid.some((flag, index) => typeof flag !== 'boolean' || (flag
100
+ ? typeof frame.values[index] !== 'number' || !Number.isFinite(frame.values[index])
101
+ : frame.values[index] !== null))) return false
102
+ latest = { values: frame.values.slice(), valid: frame.valid.slice(), seq: frame.seq }
103
+ pending = true
104
+ return true
105
+ },
106
+ take () { if (!pending) return null; pending = false; return latest },
107
+ close () { closed = true; latest = null; pending = false }
108
+ }
109
+ }
110
+
111
+ module.exports = { createMeterReceiver, createMeterRenderer, createRoomMeterReceiver }
package/meter-route.js ADDED
@@ -0,0 +1,12 @@
1
+ function meterRoute (value) {
2
+ if (value == null) return null
3
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
4
+ typeof value.streamId !== 'string' || !value.streamId.trim() || value.streamId.length > 255 ||
5
+ ![value.eventPath, value.manifestPath].every(path => typeof path === 'string' &&
6
+ /^\/\/[^/]/.test(path) && path.length <= 1026) || value.eventPath === value.manifestPath) {
7
+ throw Error('Invalid meter delivery metadata')
8
+ }
9
+ return { streamId: value.streamId, eventPath: value.eventPath, manifestPath: value.manifestPath }
10
+ }
11
+
12
+ module.exports = { meterRoute }
@@ -1,4 +1,5 @@
1
1
  const { configuredRoutes } = require('./control-bound-protocol')
2
+ const { meterRoute } = require('./meter-route')
2
3
 
3
4
  const supportedTypes = {
4
5
  boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
@@ -28,6 +29,7 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
28
29
  const nodes = []
29
30
  const readTargets = new Map()
30
31
  const writeTargets = new Map()
32
+ const meterStreams = new Map()
31
33
  const commandRules = []
32
34
  const commandWires = []
33
35
 
@@ -51,7 +53,10 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
51
53
  if (['source', 'preset'].includes(type) && !['enum', 'integer', 'string'].includes(target.type)) {
52
54
  throw new Error(`Choice control bound to ${target.type}: ${path}`)
53
55
  }
54
- if (!['mute', 'power', 'level', 'dimmer', 'position', 'text', 'source', 'preset'].includes(type)) {
56
+ if (type === 'meter' && (!target.meter || !['integer', 'float'].includes(target.type) || target.access !== 'ro')) {
57
+ throw new Error(`Meter control must bind to a readonly Meter target: ${path}`)
58
+ }
59
+ if (!['mute', 'power', 'level', 'dimmer', 'position', 'meter', 'text', 'source', 'preset'].includes(type)) {
55
60
  throw new Error(`Control type requires a separate command adapter: ${type}`)
56
61
  }
57
62
  if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
@@ -88,6 +93,47 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
88
93
  if (control.mode !== 'write') {
89
94
  if (!binding.readPath) throw new Error(`Missing read path: ${id}`)
90
95
  const target = metadata(binding.device, binding.readPath, control.type, 'r')
96
+ if (target.meter) {
97
+ const meter = meterRoute(target.meter)
98
+ if (control.mode !== 'read' || control.adapter !== 'meter') throw Error(`Meter is read-only: ${id}`)
99
+ const key = JSON.stringify([target.device, meter.streamId])
100
+ const previous = meterStreams.get(key)
101
+ if (previous) {
102
+ if (JSON.stringify(previous.meter) !== JSON.stringify(meter) || previous.onebased !== target.onebased) {
103
+ throw Error(`Conflicting meter stream: ${meter.streamId}`)
104
+ }
105
+ continue
106
+ }
107
+ const index = meterStreams.size + 1
108
+ const statusId = `${scopeId}-meter-read-${index}`
109
+ const tagIds = []
110
+ meterStreams.set(key, { meter, onebased: target.onebased, statusId, tagIds })
111
+ for (const [role, path] of [['manifest', meter.manifestPath], ['frame', meter.eventPath]]) {
112
+ const eventId = `${scopeId}-meter-${role}-${index}`
113
+ const tagId = `${eventId}-tag`
114
+ tagIds.push(tagId)
115
+ const y = 540 + (index - 1) * 280 + (role === 'frame' ? 140 : 0)
116
+ if (role === 'manifest') nodes.push({
117
+ id: statusId, type: 'Status', z: subflowId, name: `Read meter manifest ${target.device}`,
118
+ x: 440, y: y - 60, mojo: { version: '1.0', device: [target.device],
119
+ parameter: path, onebased: target.onebased }, wires: [[tagId]]
120
+ })
121
+ nodes.push({ id: eventId, type: 'Event', z: subflowId,
122
+ name: `Watch meter ${role} ${target.device} ${meter.streamId}`, x: 440, y,
123
+ mojo: { version: '1.0', device: [target.device], event: path,
124
+ eventKind: role === 'frame' ? 'Event' : 'Parameter', paramType: 'String',
125
+ onebased: target.onebased }, wires: [[tagId]] })
126
+ nodes.push({ id: tagId, type: 'change', z: subflowId,
127
+ name: `Identify meter ${role} ${target.device}`, x: 1000, y,
128
+ rules: [
129
+ { t: 'set', p: 'kind', pt: 'msg', to: 'device-meter-' + role, tot: 'str' },
130
+ { t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
131
+ { t: 'set', p: 'path', pt: 'msg', to: path, tot: 'str' },
132
+ { t: 'set', p: 'streamId', pt: 'msg', to: meter.streamId, tot: 'str' }
133
+ ], wires: [[]] })
134
+ }
135
+ continue
136
+ }
91
137
  const key = JSON.stringify([target.device, target.path])
92
138
  if (readTargets.has(key)) continue
93
139
  const index = readTargets.size + 1
@@ -128,6 +174,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
128
174
  }
129
175
 
130
176
  const commandSwitchId = `${scopeId}-commands`
177
+ // 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
131
179
  const routerId = `${scopeId}-router`
132
180
  if (commandRules.length) {
133
181
  nodes.push({
@@ -148,16 +196,17 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
148
196
  ],
149
197
  checkall: 'false', repair: false, outputs: 2,
150
198
  wires: [commandRules.length ? [commandSwitchId] : [],
151
- [...readTargets.values()].map(target => target.statusId)]
199
+ [...readTargets.values(), ...meterStreams.values()].map(target => target.statusId)]
152
200
  })
153
201
  const subflow = {
154
202
  id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
155
203
  info: 'Generated panel binding bridge. Requires a Controller node and authenticated command source.',
156
204
  category: 'panel binding',
157
205
  in: [{ x: 170, y: 150, wires: [{ id: routerId }] }],
158
- out: readTargets.size
159
- ? [{ x: 1290, y: Math.round((390 + (readTargets.size + 1) * 85) / 10) * 10,
160
- wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 })) }]
206
+ out: readTargets.size || meterStreams.size
207
+ ? [{ x: meterStreams.size ? 1600 : 1290, y: Math.round((390 + (readTargets.size + meterStreams.size + 1) * 85) / 10) * 10,
208
+ wires: [...readTargets.values()].map(target => ({ id: target.tagId, port: 0 }))
209
+ .concat([...meterStreams.values()].flatMap(target => target.tagIds.map(id => ({ id, port: 0 })))) }]
161
210
  : [],
162
211
  env: [], meta: {}, color: '#96bca7'
163
212
  }
@@ -166,7 +215,8 @@ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
166
215
  manifest: {
167
216
  subflowId,
168
217
  controls: routes.size,
169
- nativeReads: readTargets.size,
218
+ nativeReads: readTargets.size + meterStreams.size,
219
+ nativeMeterStreams: meterStreams.size,
170
220
  nativeWrites: writeTargets.size,
171
221
  requiresController: true,
172
222
  initialReadMessage: { kind: 'read-all' }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.15",
3
+ "version": "1.9.0",
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.15",
9
+ "version": "1.9.0",
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.15",
3
+ "version": "1.9.0",
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
  ],
@@ -21,6 +23,10 @@
21
23
  "control-catalog.js",
22
24
  "control-type-contract.json",
23
25
  "control-type-model.js",
26
+ "meter-route.js",
27
+ "meter-frame.js",
28
+ "panel-meter.js",
29
+ "panel-meter-client.js",
24
30
  "panel-template-instrumentation.js",
25
31
  "panel-visual-bindings.js",
26
32
  "panel-visual-apply.js",