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.
- package/CONTROL_PANEL_AI_GUIDE.md +55 -220
- package/README.md +10 -148
- package/assets/panel-login-bg.png +0 -0
- package/control-bound-protocol.js +97 -0
- package/control-catalog.js +97 -0
- package/control-panel.html +82 -86
- package/control-panel.js +237 -10
- package/control-policy.js +267 -7
- package/control-type-contract.json +17 -0
- package/control-type-model.js +63 -0
- package/gateway.cjs +77 -5
- package/managed-bridge-admin.js +90 -0
- package/managed-bridge-revision.js +32 -0
- package/managed-native-bridge.js +124 -0
- package/native-binding-plan.js +177 -0
- package/package.json +27 -5
- package/panel-binding-editor.js +771 -0
- package/panel-editor-auth.js +151 -0
- package/panel-template-instrumentation.js +267 -0
- package/panel-visual-apply.js +127 -0
- package/panel-visual-bindings.js +95 -0
- package/room-gateway.html +0 -21
- package/room-gateway.js +0 -57
- package/room-message.html +0 -56
- package/room-message.js +0 -43
|
@@ -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.
|
|
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": [
|
|
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": [
|
|
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
|
}
|