node-red-contrib-room-tools 1.8.2 → 1.8.3

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.
@@ -0,0 +1,90 @@
1
+ const crypto = require('node:crypto')
2
+ const fs = require('node:fs')
3
+ const path = require('node:path')
4
+
5
+ function flowDigest (flows) {
6
+ return crypto.createHash('sha256').update(JSON.stringify(flows)).digest('hex')
7
+ }
8
+
9
+ function localDirectory (userDir, remoteAddress) {
10
+ const address = String(remoteAddress || '')
11
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
12
+ throw new Error('Managed bridge editor is restricted to a local request')
13
+ }
14
+ return fs.realpathSync(userDir)
15
+ }
16
+
17
+ function regularFile (file) {
18
+ return fs.existsSync(file) && fs.lstatSync(file).isFile()
19
+ }
20
+
21
+ function flowLocation (directory) {
22
+ if (directory.includes(`${path.sep}local-validation${path.sep}`)) {
23
+ const flowFile = path.join(directory, 'flows.json')
24
+ if (!regularFile(flowFile)) throw new Error('Local-validation Flow file is missing')
25
+ return { flowFile, credentialFile: path.join(directory, 'flows_cred.json'),
26
+ backupRoot: path.join(directory, 'managed-bridge-backups') }
27
+ }
28
+
29
+ const configFile = path.join(directory, '.config.projects.json')
30
+ if (!regularFile(configFile)) {
31
+ throw new Error('Managed bridge editor requires a local-validation directory or an active Node-RED project')
32
+ }
33
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'))
34
+ const name = config.activeProject
35
+ if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ||
36
+ !Object.hasOwn(config.projects || {}, name)) {
37
+ throw new Error('Active Node-RED project is missing or invalid')
38
+ }
39
+ const projectsRoot = fs.realpathSync(path.join(directory, 'projects'))
40
+ if (path.dirname(projectsRoot) !== directory) throw new Error('Projects directory escapes the user directory')
41
+ const projectPath = path.join(projectsRoot, name)
42
+ if (!fs.lstatSync(projectPath).isDirectory()) throw new Error('Active project must be a directory')
43
+ const projectDir = fs.realpathSync(projectPath)
44
+ if (path.dirname(projectDir) !== projectsRoot) throw new Error('Active project escapes the projects directory')
45
+ const packageFile = path.join(projectDir, 'package.json')
46
+ if (!regularFile(packageFile)) throw new Error('Active project package.json is missing')
47
+ const settings = JSON.parse(fs.readFileSync(packageFile, 'utf8'))['node-red']?.settings
48
+ const validName = name => typeof name === 'string' &&
49
+ /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(name) && path.basename(name) === name
50
+ if (!validName(settings?.flowFile) || !validName(settings?.credentialsFile) ||
51
+ settings.flowFile === settings.credentialsFile) {
52
+ throw new Error('Active project must declare safe flowFile and credentialsFile names')
53
+ }
54
+ const flowFile = path.join(projectDir, settings.flowFile)
55
+ const credentialFile = path.join(projectDir, settings.credentialsFile)
56
+ if (!regularFile(flowFile)) throw new Error('Active project Flow file is missing or unsafe')
57
+ if (fs.existsSync(credentialFile) && !regularFile(credentialFile)) {
58
+ throw new Error('Active project credentials file must be a regular file')
59
+ }
60
+ return { flowFile, credentialFile,
61
+ backupRoot: path.join(directory, 'managed-bridge-backups', name) }
62
+ }
63
+
64
+ function privateDirectory (directory) {
65
+ if (fs.existsSync(directory)) {
66
+ if (!fs.lstatSync(directory).isDirectory()) throw new Error('Bridge backup path must be a directory')
67
+ } else {
68
+ fs.mkdirSync(directory, { mode: 0o700 })
69
+ }
70
+ }
71
+
72
+ function backupFlow (location, rev, flows) {
73
+ const parent = path.dirname(location.backupRoot)
74
+ if (parent !== path.dirname(location.flowFile)) privateDirectory(parent)
75
+ privateDirectory(location.backupRoot)
76
+ const backupDir = path.join(location.backupRoot,
77
+ new Date().toISOString().replace(/[:.]/g, '-') + '-' + crypto.randomBytes(3).toString('hex'))
78
+ fs.mkdirSync(backupDir, { mode: 0o700 })
79
+ fs.writeFileSync(path.join(backupDir, 'api-flows.json'), JSON.stringify({ rev, flows }, null, 2),
80
+ { flag: 'wx', mode: 0o600 })
81
+ for (const file of [location.flowFile, location.credentialFile]) {
82
+ if (!regularFile(file)) continue
83
+ const target = path.join(backupDir, path.basename(file))
84
+ fs.copyFileSync(file, target)
85
+ fs.chmodSync(target, 0o600)
86
+ }
87
+ return backupDir
88
+ }
89
+
90
+ module.exports = { backupFlow, flowDigest, localDirectory, flowLocation }
@@ -0,0 +1,32 @@
1
+ const crypto = require('node:crypto')
2
+ const { configuredRoutes } = require('./control-bound-protocol')
3
+
4
+ function canonical (value) {
5
+ if (Array.isArray(value)) return value.map(canonical)
6
+ if (value && typeof value === 'object') {
7
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
8
+ }
9
+ return value
10
+ }
11
+
12
+ function managedBridgeRevision (panel) {
13
+ const routes = [...configuredRoutes(panel)].map(([id, { control, binding }]) => ({
14
+ id,
15
+ type: control.type,
16
+ mode: control.mode,
17
+ device: binding?.device || '',
18
+ readPath: binding?.readPath || '',
19
+ writePath: binding?.writePath || '',
20
+ readMeta: binding?.readMeta || null,
21
+ writeMeta: binding?.writeMeta || null
22
+ })).sort((a, b) => a.id.localeCompare(b.id))
23
+ const payload = canonical({
24
+ panelId: panel.id,
25
+ room: panel.boundRoom,
26
+ nativeBridge: panel.nativeBridge === true,
27
+ routes
28
+ })
29
+ return `v1:${crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex')}`
30
+ }
31
+
32
+ module.exports = { managedBridgeRevision }
@@ -0,0 +1,124 @@
1
+ const crypto = require('node:crypto')
2
+ const { configuredRoutes } = require('./control-bound-protocol')
3
+ const { compileNativeBindingPlan } = require('./native-binding-plan')
4
+ const { managedBridgeRevision } = require('./managed-bridge-revision')
5
+
6
+ const MARKER = 'control-panel-managed-v1'
7
+
8
+ function digest (value) {
9
+ return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
10
+ }
11
+
12
+ function panelTargets (panel) {
13
+ const targets = new Map()
14
+ for (const [id, { control, binding }] of configuredRoutes(panel)) {
15
+ if (!binding?.device) throw new Error(`Missing device binding: ${id}`)
16
+ for (const direction of ['read', 'write']) {
17
+ if ((direction === 'read' && control.mode === 'write') ||
18
+ (direction === 'write' && control.mode === 'read')) continue
19
+ const path = binding[`${direction}Path`]
20
+ const meta = binding[`${direction}Meta`]
21
+ if (!path || !meta || typeof meta !== 'object' || Array.isArray(meta)) {
22
+ throw new Error(`Missing ${direction} target metadata: ${id}`)
23
+ }
24
+ const target = {
25
+ device: binding.device, path, kind: meta.kind, type: meta.type,
26
+ access: meta.access, onebased: meta.onebased,
27
+ ...(meta.booleanEncoding ? { booleanEncoding: meta.booleanEncoding } : {})
28
+ }
29
+ const key = JSON.stringify([target.device, target.path])
30
+ const previous = targets.get(key)
31
+ if (previous && digest(previous) !== digest(target)) {
32
+ throw new Error(`Conflicting target metadata: ${target.device} ${target.path}`)
33
+ }
34
+ targets.set(key, target)
35
+ }
36
+ }
37
+ return [...targets.values()]
38
+ }
39
+
40
+ function generatedBridge (panel) {
41
+ const scopeId = `cp${digest(panel.id).slice(0, 12)}`
42
+ const { nodes, manifest } = compileNativeBindingPlan({ panel, targets: panelTargets(panel), scopeId })
43
+ const subflow = nodes[0]
44
+ subflow.info = `${MARKER}:${panel.id}:${digest(nodes.slice(1))}`
45
+ const instanceId = `${scopeId}-instance`
46
+ const instance = {
47
+ id: instanceId,
48
+ type: `subflow:${manifest.subflowId}`,
49
+ z: panel.z,
50
+ name: `Managed bridge for ${panel.id}`,
51
+ x: Number(panel.x || 200) + 240,
52
+ y: Number(panel.y || 200),
53
+ wires: manifest.nativeReads ? [[panel.id]] : []
54
+ }
55
+ return { nodes, instance, manifest, scopeId }
56
+ }
57
+
58
+ function removeBridgeNodes (flows, subflowId, instanceId) {
59
+ const removeIds = new Set([subflowId, instanceId])
60
+ for (const node of flows) {
61
+ if (node.z === subflowId || node.type === `subflow:${subflowId}`) {
62
+ removeIds.add(node.id)
63
+ }
64
+ }
65
+
66
+ // Remove references from any remaining node before the generated bridge is
67
+ // replaced. The panel itself is assigned its new wire immediately after.
68
+ for (const node of flows) {
69
+ if (removeIds.has(node.id) || !Array.isArray(node.wires)) continue
70
+ node.wires = node.wires.map(port => Array.isArray(port)
71
+ ? port.filter(id => !removeIds.has(id))
72
+ : port)
73
+ }
74
+
75
+ for (let index = flows.length - 1; index >= 0; index--) {
76
+ if (removeIds.has(flows[index].id)) flows.splice(index, 1)
77
+ }
78
+ }
79
+
80
+ function provisionManagedBridges (currentFlows, options = {}) {
81
+ if (!Array.isArray(currentFlows)) throw new Error('Expected a Node-RED Flow array')
82
+ const flows = structuredClone(currentFlows)
83
+ const scopedPanelId = options.panelId || null
84
+ const owners = flows.filter(node => node.type === 'ui-control-panel' && node.nativeBridge === true &&
85
+ (!scopedPanelId || node.id === scopedPanelId))
86
+ const updated = []
87
+ for (const panel of owners) {
88
+ if (!panel.id || !panel.z) throw new Error('Managed panel needs an ID and Flow tab')
89
+ const bridge = generatedBridge(panel)
90
+ const revision = managedBridgeRevision(panel)
91
+ const { nodes, instance, manifest, scopeId } = bridge
92
+ const subflowId = manifest.subflowId
93
+
94
+ // The bridge is derived state. Always remove the complete previous
95
+ // generation for this panel, including moved or older-version nodes.
96
+ removeBridgeNodes(flows, subflowId, instance.id)
97
+ panel.wires = [[instance.id]]
98
+ panel.bridgeRevisionApplied = revision
99
+ flows.push(...nodes, instance)
100
+ updated.push({ panelId: panel.id, room: panel.boundRoom, controls: manifest.controls,
101
+ reads: manifest.nativeReads, writes: manifest.nativeWrites, scopeId })
102
+ }
103
+ for (const subflow of [...flows]) {
104
+ if (subflow.type !== 'subflow' || typeof subflow.info !== 'string' ||
105
+ !subflow.info.startsWith(`${MARKER}:`)) continue
106
+ const match = subflow.info.match(/^control-panel-managed-v1:([^:]+):([a-f0-9]{64})$/)
107
+ if (!match) continue
108
+ const ownerId = match[1]
109
+ if (scopedPanelId && ownerId !== scopedPanelId) continue
110
+ if (owners.some(panel => panel.id === ownerId)) continue
111
+ const scopeId = `cp${digest(ownerId).slice(0, 12)}`
112
+ const instanceId = `${scopeId}-instance`
113
+ const oldPanel = flows.find(node => node.id === ownerId)
114
+ if (oldPanel) {
115
+ oldPanel.wires = [[]]
116
+ oldPanel.bridgeRevisionApplied = ''
117
+ }
118
+ removeBridgeNodes(flows, subflow.id, instanceId)
119
+ updated.push({ panelId: ownerId, removed: true })
120
+ }
121
+ return { flows, updated, managedPanels: owners.length }
122
+ }
123
+
124
+ module.exports = { panelTargets, generatedBridge, provisionManagedBridges }
@@ -0,0 +1,177 @@
1
+ const { configuredRoutes } = require('./control-bound-protocol')
2
+
3
+ const supportedTypes = {
4
+ boolean: { eventType: 'Boolean', eventOptions: { boolParamFilter: 'Any' } },
5
+ integer: { eventType: 'Integer', eventOptions: { normalized: false } },
6
+ float: { eventType: 'Float', eventOptions: { normalized: false } },
7
+ string: { eventType: 'String', eventOptions: {} },
8
+ dstring: { eventType: 'DString (comma separated string)', eventOptions: {} },
9
+ enum: { eventType: 'Enumeration', eventOptions: {} }
10
+ }
11
+
12
+ function compileNativeBindingPlan ({ panel, targets, scopeId }) {
13
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,48}$/.test(scopeId || '')) {
14
+ throw new Error('A unique, stable scopeId is required')
15
+ }
16
+ if (!Array.isArray(targets)) throw new Error('Target descriptors are required')
17
+ const routes = configuredRoutes(panel)
18
+ if (!routes.size) throw new Error('No visual bindings to compile')
19
+
20
+ const targetIndex = new Map()
21
+ for (const target of targets) {
22
+ const key = JSON.stringify([target.device, target.path])
23
+ if (targetIndex.has(key)) throw new Error(`Duplicate target descriptor: ${key}`)
24
+ targetIndex.set(key, target)
25
+ }
26
+
27
+ const subflowId = `${scopeId}-bridge`
28
+ const nodes = []
29
+ const readTargets = new Map()
30
+ const writeTargets = new Map()
31
+ const commandRules = []
32
+ const commandWires = []
33
+
34
+ function metadata (device, path, type, access) {
35
+ const target = targetIndex.get(JSON.stringify([device, path]))
36
+ if (!target) throw new Error(`Missing target descriptor: ${device} ${path}`)
37
+ if (target.kind !== 'param') throw new Error(`Only parameter targets are supported: ${path}`)
38
+ if (!Object.hasOwn(supportedTypes, target.type)) {
39
+ throw new Error(`Unsupported native parameter type: ${path} (${target.type})`)
40
+ }
41
+ if (['mute', 'power'].includes(type) && target.type !== 'boolean' &&
42
+ !(target.type === 'integer' && target.booleanEncoding === 'integer01')) {
43
+ throw new Error(`Boolean control bound to ${target.type}: ${path}`)
44
+ }
45
+ if (['level', 'dimmer', 'position'].includes(type) && !['integer', 'float'].includes(target.type)) {
46
+ throw new Error(`Numeric control bound to ${target.type}: ${path}`)
47
+ }
48
+ if (type === 'text' && !['string', 'dstring'].includes(target.type)) {
49
+ throw new Error(`Text control bound to ${target.type}: ${path}`)
50
+ }
51
+ if (['source', 'preset'].includes(type) && !['enum', 'integer', 'string'].includes(target.type)) {
52
+ throw new Error(`Choice control bound to ${target.type}: ${path}`)
53
+ }
54
+ if (!['mute', 'power', 'level', 'dimmer', 'position', 'text', 'source', 'preset'].includes(type)) {
55
+ throw new Error(`Control type requires a separate command adapter: ${type}`)
56
+ }
57
+ if (!['ro', 'wo', 'rw'].includes(target.access) || !target.access.includes(access)) {
58
+ throw new Error(`Target is not ${access === 'r' ? 'readable' : 'writable'}: ${path}`)
59
+ }
60
+ if (typeof target.onebased !== 'boolean') {
61
+ throw new Error(`Target numbering mode is required: ${path}`)
62
+ }
63
+ return target
64
+ }
65
+
66
+ for (const [id, { control, binding }] of routes) {
67
+ if (!binding?.device) throw new Error(`Missing device binding: ${id}`)
68
+ if (control.mode !== 'read') {
69
+ if (!binding.writePath) throw new Error(`Missing write path: ${id}`)
70
+ const target = metadata(binding.device, binding.writePath, control.type, 'w')
71
+ const key = JSON.stringify([target.device, target.path])
72
+ if (!writeTargets.has(key)) {
73
+ const nodeId = `${scopeId}-write-${writeTargets.size + 1}`
74
+ writeTargets.set(key, nodeId)
75
+ nodes.push({
76
+ id: nodeId, type: 'Command', z: subflowId,
77
+ name: `Write ${target.device} ${target.path}`,
78
+ x: 1860, y: 10 + writeTargets.size * 110,
79
+ mojo: { version: '1.0', device: [target.device], command: target.path,
80
+ onebased: target.onebased, cmdvalue: 'Message payload' },
81
+ wires: [[]]
82
+ })
83
+ }
84
+ commandRules.push({ t: 'eq', v: id, vt: 'str' })
85
+ commandWires.push([writeTargets.get(key)])
86
+ }
87
+
88
+ if (control.mode !== 'write') {
89
+ if (!binding.readPath) throw new Error(`Missing read path: ${id}`)
90
+ const target = metadata(binding.device, binding.readPath, control.type, 'r')
91
+ const key = JSON.stringify([target.device, target.path])
92
+ if (readTargets.has(key)) continue
93
+ const index = readTargets.size + 1
94
+ const statusId = `${scopeId}-read-${index}`
95
+ const eventId = `${scopeId}-watch-${index}`
96
+ const tagId = `${scopeId}-tag-${index}`
97
+ readTargets.set(key, { statusId, eventId, tagId })
98
+ const event = supportedTypes[target.type]
99
+ nodes.push({
100
+ id: statusId, type: 'Status', z: subflowId,
101
+ name: `Read ${target.device} ${target.path}`,
102
+ x: 440, y: 360 + index * 170,
103
+ mojo: { version: '1.0', device: [target.device], parameter: target.path,
104
+ onebased: target.onebased },
105
+ wires: [[tagId]]
106
+ })
107
+ nodes.push({
108
+ id: eventId, type: 'Event', z: subflowId,
109
+ name: `Watch ${target.device} ${target.path}`,
110
+ x: 440, y: 420 + index * 170,
111
+ mojo: { version: '1.0', device: [target.device], event: target.path,
112
+ eventKind: 'Parameter', paramType: event.eventType,
113
+ onebased: target.onebased, ...event.eventOptions },
114
+ wires: [[tagId]]
115
+ })
116
+ nodes.push({
117
+ id: tagId, type: 'change', z: subflowId,
118
+ name: `Identify ${target.device} ${target.path}`,
119
+ x: 900, y: 390 + index * 170,
120
+ rules: [
121
+ { t: 'set', p: 'kind', pt: 'msg', to: 'device-feedback', tot: 'str' },
122
+ { t: 'set', p: 'device', pt: 'msg', to: target.device, tot: 'str' },
123
+ { t: 'set', p: 'path', pt: 'msg', to: target.path, tot: 'str' }
124
+ ],
125
+ wires: [[]]
126
+ })
127
+ }
128
+ }
129
+
130
+ const commandSwitchId = `${scopeId}-commands`
131
+ const routerId = `${scopeId}-router`
132
+ if (commandRules.length) {
133
+ nodes.push({
134
+ id: commandSwitchId, type: 'switch', z: subflowId,
135
+ name: 'Control ID', property: 'controlId', propertyType: 'msg',
136
+ x: 1440, y: 140,
137
+ rules: commandRules, checkall: 'false', repair: false,
138
+ outputs: commandRules.length, wires: commandWires
139
+ })
140
+ }
141
+ nodes.push({
142
+ id: routerId, type: 'switch', z: subflowId,
143
+ name: 'Command or initial read', property: 'kind', propertyType: 'msg',
144
+ x: 430, y: 150,
145
+ rules: [
146
+ { t: 'eq', v: 'bound-command', vt: 'str' },
147
+ { t: 'eq', v: 'read-all', vt: 'str' }
148
+ ],
149
+ checkall: 'false', repair: false, outputs: 2,
150
+ wires: [commandRules.length ? [commandSwitchId] : [],
151
+ [...readTargets.values()].map(target => target.statusId)]
152
+ })
153
+ const subflow = {
154
+ id: subflowId, type: 'subflow', name: `Binding bridge ${scopeId}`,
155
+ info: 'Generated local PoC. Requires a Controller node and authenticated command source.',
156
+ category: 'panel binding',
157
+ 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 })) }]
161
+ : [],
162
+ env: [], meta: {}, color: '#96bca7'
163
+ }
164
+ return {
165
+ nodes: [subflow, ...nodes],
166
+ manifest: {
167
+ subflowId,
168
+ controls: routes.size,
169
+ nativeReads: readTargets.size,
170
+ nativeWrites: writeTargets.size,
171
+ requiresController: true,
172
+ initialReadMessage: { kind: 'read-all' }
173
+ }
174
+ }
175
+ }
176
+
177
+ module.exports = { compileNativeBindingPlan }
package/package.json CHANGED
@@ -1,18 +1,40 @@
1
1
  {
2
2
  "name": "node-red-contrib-room-tools",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "Room panel message generator and authenticated region gateway for Node-RED and MUSE Automator",
5
5
  "license": "UNLICENSED",
6
- "keywords": ["node-red"],
6
+ "keywords": [
7
+ "node-red"
8
+ ],
7
9
  "dependencies": {
10
+ "@vue/compiler-sfc": "^3.5.42",
8
11
  "acorn": "^8.15.0",
9
12
  "escodegen": "^2.1.0"
10
13
  },
11
- "files": ["gateway.cjs", "room-gateway.js", "room-gateway.html", "room-message.js", "room-message.html", "control-panel.js", "control-panel.html", "control-policy.js", "CONTROL_PANEL_AI_GUIDE.md", "README.md"],
14
+ "files": [
15
+ "assets/panel-login-bg.png",
16
+ "gateway.cjs",
17
+ "control-panel.js",
18
+ "control-panel.html",
19
+ "control-bound-protocol.js",
20
+ "control-catalog.js",
21
+ "control-type-contract.json",
22
+ "control-type-model.js",
23
+ "panel-template-instrumentation.js",
24
+ "panel-visual-bindings.js",
25
+ "panel-visual-apply.js",
26
+ "panel-editor-auth.js",
27
+ "panel-binding-editor.js",
28
+ "native-binding-plan.js",
29
+ "managed-native-bridge.js",
30
+ "managed-bridge-revision.js",
31
+ "managed-bridge-admin.js",
32
+ "control-policy.js",
33
+ "CONTROL_PANEL_AI_GUIDE.md",
34
+ "README.md"
35
+ ],
12
36
  "node-red": {
13
37
  "nodes": {
14
- "room-gateway": "room-gateway.js",
15
- "room-message": "room-message.js",
16
38
  "ui-control-panel": "control-panel.js"
17
39
  }
18
40
  }