node-red-contrib-room-tools 1.8.1 → 1.8.3-visual-binding.17

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,151 @@
1
+ const crypto = require('node:crypto')
2
+
3
+ function createPanelEditor ({ panelId, username, password, secureCookie, getConfig, getDevices, getTargets, apply, restoredSessions }) {
4
+ if (!username || !password) return null
5
+ const prefix = `/control-panel-edit/panels/${panelId}`
6
+ const cookieName = `cp_edit_${panelId.replace(/[^a-zA-Z0-9]/g, '_')}`
7
+ const salt = crypto.randomBytes(32)
8
+ const passwordHash = crypto.scryptSync(password, salt, 32)
9
+ const sessions = new Map()
10
+ if (restoredSessions instanceof Map) {
11
+ for (const [token, session] of restoredSessions) {
12
+ if (sessions.size >= 100) break
13
+ if (typeof token === 'string' && typeof session?.csrf === 'string' &&
14
+ Number.isFinite(session.expiry) && session.expiry > Date.now()) {
15
+ sessions.set(token, { csrf: session.csrf, expiry: session.expiry })
16
+ }
17
+ }
18
+ }
19
+ const attempts = new Map()
20
+ const ttl = 30 * 60 * 1000
21
+ let closed = false
22
+
23
+ function reply (res, status, data, headers = {}) {
24
+ res.writeHead(status, {
25
+ 'Content-Type': 'application/json; charset=utf-8',
26
+ 'Cache-Control': 'no-store',
27
+ 'X-Content-Type-Options': 'nosniff',
28
+ ...headers
29
+ })
30
+ res.end(JSON.stringify(data))
31
+ }
32
+
33
+ function cookie (value, age) {
34
+ return `${cookieName}=${value}; Path=${prefix}; HttpOnly; SameSite=Strict; Max-Age=${age}${secureCookie ? '; Secure' : ''}`
35
+ }
36
+
37
+ function sameOrigin (req) {
38
+ const origin = req.headers.origin
39
+ if (!origin) return false
40
+ try {
41
+ const parsed = new URL(origin)
42
+ return parsed.host === req.headers.host &&
43
+ parsed.protocol === (req.socket.encrypted ? 'https:' : 'http:')
44
+ } catch (_) { return false }
45
+ }
46
+
47
+ function localOrSecure (req) {
48
+ const address = String(req.socket.remoteAddress || '')
49
+ return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address) ||
50
+ req.socket.encrypted === true
51
+ }
52
+
53
+ async function jsonBody (req) {
54
+ if (!String(req.headers['content-type'] || '').startsWith('application/json')) {
55
+ throw new Error('JSON body required')
56
+ }
57
+ if (req.body !== undefined) {
58
+ if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'))
59
+ if (typeof req.body === 'string') return JSON.parse(req.body)
60
+ if (typeof req.body === 'object' && req.body !== null && Object.keys(req.body).length) return req.body
61
+ }
62
+ let raw = ''
63
+ for await (const chunk of req) {
64
+ raw += chunk
65
+ if (Buffer.byteLength(raw) > 65536) throw new Error('Request too large')
66
+ }
67
+ return JSON.parse(raw)
68
+ }
69
+
70
+ async function handler (req, res) {
71
+ const path = new URL(req.url, 'http://localhost').pathname
72
+ if (!path.startsWith(prefix + '/')) return false
73
+ if (closed) { reply(res, 503, { error: 'Editor unavailable' }); return true }
74
+ if (!localOrSecure(req)) { reply(res, 403, { error: 'HTTPS required' }); return true }
75
+ if (req.method === 'POST' && !sameOrigin(req)) {
76
+ reply(res, 403, { error: 'Origin rejected' })
77
+ return true
78
+ }
79
+ const now = Date.now()
80
+ for (const [token, session] of sessions) if (session.expiry <= now) sessions.delete(token)
81
+ for (const [ip, attempt] of attempts) if (attempt.until <= now) attempts.delete(ip)
82
+ try {
83
+ if (path === prefix + '/login' && req.method === 'POST') {
84
+ const ip = String(req.socket.remoteAddress || 'unknown')
85
+ const attempt = attempts.get(ip) || { count: 0, until: now + 60000 }
86
+ if (attempt.count >= 10 || attempts.size > 1000) {
87
+ reply(res, 429, { error: 'Too many attempts' })
88
+ return true
89
+ }
90
+ attempt.count++
91
+ attempts.set(ip, attempt)
92
+ const body = await jsonBody(req)
93
+ const supplied = String(body.password || '')
94
+ if (supplied.length > 256) { reply(res, 401, { error: 'Invalid credentials' }); return true }
95
+ const test = await new Promise((resolve, reject) => crypto.scrypt(supplied, salt, 32,
96
+ (error, key) => error ? reject(error) : resolve(key)))
97
+ if (body.username !== username || !crypto.timingSafeEqual(test, passwordHash)) {
98
+ reply(res, 401, { error: 'Invalid credentials' })
99
+ return true
100
+ }
101
+ attempts.delete(ip)
102
+ if (sessions.size >= 100) { reply(res, 503, { error: 'Session limit reached' }); return true }
103
+ const token = crypto.randomBytes(32).toString('hex')
104
+ const csrf = crypto.randomBytes(24).toString('hex')
105
+ sessions.set(token, { csrf, expiry: now + ttl })
106
+ reply(res, 200, { csrf }, { 'Set-Cookie': cookie(token, ttl / 1000) })
107
+ return true
108
+ }
109
+ const token = String(req.headers.cookie || '').split(';').map(part => part.trim())
110
+ .find(part => part.startsWith(cookieName + '='))?.slice(cookieName.length + 1)
111
+ const session = sessions.get(token)
112
+ if (!session) { reply(res, 401, { error: 'Editor login required' }); return true }
113
+ if (req.method === 'POST' && req.headers['x-panel-csrf'] !== session.csrf) {
114
+ reply(res, 403, { error: 'CSRF rejected' })
115
+ return true
116
+ }
117
+ if (path === prefix + '/session' && req.method === 'GET') {
118
+ reply(res, 200, { csrf: session.csrf })
119
+ } else if (path === prefix + '/logout' && req.method === 'POST') {
120
+ sessions.delete(token)
121
+ reply(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) })
122
+ } else if (path === prefix + '/config' && req.method === 'GET') {
123
+ reply(res, 200, await getConfig(req))
124
+ } else if (path === prefix + '/devices' && req.method === 'GET') {
125
+ reply(res, 200, { devices: await getDevices(req) })
126
+ } else if (path === prefix + '/targets' && req.method === 'GET') {
127
+ const device = new URL(req.url, 'http://localhost').searchParams.get('device')
128
+ reply(res, 200, { targets: await getTargets(req, device) })
129
+ } else if (path === prefix + '/apply' && req.method === 'POST') {
130
+ reply(res, 200, await apply(req, await jsonBody(req)))
131
+ } else {
132
+ reply(res, 404, { error: 'Not found' })
133
+ }
134
+ return true
135
+ } catch (error) {
136
+ reply(res, error.status || 400, { error: error.message })
137
+ return true
138
+ }
139
+ }
140
+
141
+ return {
142
+ handler,
143
+ sessionSnapshot () {
144
+ return new Map([...sessions].filter(([, session]) => session.expiry > Date.now())
145
+ .map(([token, session]) => [token, { ...session }]))
146
+ },
147
+ close () { closed = true; sessions.clear(); attempts.clear() }
148
+ }
149
+ }
150
+
151
+ module.exports = { createPanelEditor }
@@ -0,0 +1,267 @@
1
+ const crypto = require('node:crypto')
2
+ const { parse } = require('@vue/compiler-sfc')
3
+
4
+ const hash = value => crypto.createHash('sha256').update(value).digest('hex').slice(0, 20)
5
+ const COMPONENT_ADAPTERS = Object.freeze({
6
+ 'v-slider': 'range', 'v-switch': 'switch', 'v-checkbox': 'switch',
7
+ 'v-select': 'select', 'v-radio-group': 'select',
8
+ 'v-text-field': 'text-input', 'v-textarea': 'text-input',
9
+ 'v-progress-linear': 'meter', 'v-progress-circular': 'meter'
10
+ })
11
+
12
+ function attribute (node, name) {
13
+ const prop = (node.props || []).find(item => item.type === 6 && item.name === name)
14
+ return prop?.value?.content || ''
15
+ }
16
+
17
+ function hasDirective (node, names) {
18
+ return (node.props || []).some(prop => prop.type === 7 && names.includes(prop.name))
19
+ }
20
+
21
+ function hasBoundAttribute (node, names) {
22
+ return (node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
23
+ names.includes(prop.arg?.content))
24
+ }
25
+
26
+ function hasAttribute (node, names) {
27
+ return (node.props || []).some(prop => prop.type === 6 && names.includes(prop.name)) ||
28
+ hasBoundAttribute(node, names)
29
+ }
30
+
31
+ function hasNestedHandlers (node) {
32
+ return (node.children || []).some(child => hasDirective(child, ['on']) || hasNestedHandlers(child))
33
+ }
34
+
35
+ function enabledBooleanProp (node, names) {
36
+ return (node.props || []).some(prop => {
37
+ if (prop.type === 6) return names.includes(prop.name)
38
+ return prop.type === 7 && prop.name === 'bind' && names.includes(prop.arg?.content) &&
39
+ prop.exp?.content?.trim() !== 'false'
40
+ })
41
+ }
42
+
43
+ function candidateAdapter (node) {
44
+ const tag = node.tag.toLowerCase()
45
+ if (COMPONENT_ADAPTERS[tag]) return COMPONENT_ADAPTERS[tag]
46
+ if (tag === 'input') {
47
+ const type = (attribute(node, 'type') || 'text').toLowerCase()
48
+ if (type === 'range' || type === 'number') return 'range'
49
+ if (type === 'checkbox') return 'switch'
50
+ if (type === 'text') return 'text-input'
51
+ return null
52
+ }
53
+ if (tag === 'textarea') return 'text-input'
54
+ if (tag === 'select') return 'select'
55
+ if (tag === 'button' && (attribute(node, 'aria-pressed') !== '' || attribute(node, 'role') === 'switch' ||
56
+ hasBoundAttribute(node, ['aria-pressed', 'aria-checked']))) return 'switch'
57
+ if (tag === 'meter' || tag === 'progress') return 'meter'
58
+ if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
59
+ node.children?.length === 1 && node.children[0].type === 2 &&
60
+ node.children[0].content.trim()) return 'text-display'
61
+ if (['span', 'strong', 'b', 'output', 'h1', 'h2', 'h3'].includes(tag) &&
62
+ node.children?.some(child => child.type === 5)) return 'text-display'
63
+ return null
64
+ }
65
+
66
+ function staticNumericProp (node, name) {
67
+ const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
68
+ prop.arg?.content === name)
69
+ if (bound) {
70
+ const expression = bound.exp?.content?.trim() || ''
71
+ return /^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(expression)
72
+ ? { value: Number(expression), dynamic: false }
73
+ : { value: undefined, dynamic: true }
74
+ }
75
+ const value = attribute(node, name)
76
+ if (value === '' || (name === 'step' && value === 'any')) return { value: undefined, dynamic: false }
77
+ const numeric = Number(value)
78
+ return { value: numeric, dynamic: !Number.isFinite(numeric) }
79
+ }
80
+
81
+ function displayModel (node) {
82
+ const dynamic = (node.children || []).filter(child => child.type === 5)
83
+ if (!dynamic.length) return { model: '', valid: true }
84
+ const expression = dynamic.length === 1 ? dynamic[0].content?.content?.trim() : ''
85
+ return { model: expression, valid: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(expression) }
86
+ }
87
+
88
+ function inputModel (node, allowNumber = false) {
89
+ const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'model')
90
+ if (!directive) return { model: '', valid: true }
91
+ const model = directive.exp?.content?.trim() || ''
92
+ const modifiers = (directive.modifiers || []).map(item => typeof item === 'string' ? item : item.content)
93
+ return { model, valid: !directive.arg && modifiers.every(item => allowNumber && item === 'number') &&
94
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
95
+ }
96
+
97
+ function progressModel (node) {
98
+ const bound = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
99
+ ['model-value', 'modelValue'].includes(prop.arg?.content))
100
+ if (!bound) return inputModel(node)
101
+ const model = bound.exp?.content?.trim() || ''
102
+ return { model, valid: !hasDirective(node, ['model']) && !bound.modifiers?.length &&
103
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
104
+ }
105
+
106
+ function pressedModel (node) {
107
+ const directive = (node.props || []).find(prop => prop.type === 7 && prop.name === 'bind' &&
108
+ ['aria-pressed', 'aria-checked'].includes(prop.arg?.content))
109
+ if (!directive) return { model: '', valid: true }
110
+ const model = directive.exp?.content?.trim() || ''
111
+ return { model, valid: !directive.modifiers?.length &&
112
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(model) }
113
+ }
114
+
115
+ function openingTagEnd (source, start) {
116
+ let quote = ''
117
+ for (let index = start; index < source.length; index++) {
118
+ const char = source[index]
119
+ if (quote) {
120
+ if (char === quote) quote = ''
121
+ } else if (char === '"' || char === "'") quote = char
122
+ else if (char === '>') return index
123
+ }
124
+ throw new Error('Unclosed element in ControlPanel template')
125
+ }
126
+
127
+ function inspectTemplate (format) {
128
+ if (typeof format !== 'string' || format.length > 1024 * 1024) {
129
+ throw new Error('ControlPanel template must be text under 1 MB')
130
+ }
131
+ const { descriptor, errors } = parse(format, { filename: 'ControlPanel.vue' })
132
+ if (errors.length) throw new Error('Invalid ControlPanel template: ' + errors[0].message)
133
+ const candidates = []
134
+ const counts = new Map()
135
+
136
+ function visit (node, repeated = false) {
137
+ if (node.type === 1) {
138
+ if (node.tag.toLowerCase().startsWith('v-') && node.isSelfClosing) {
139
+ throw new Error(`Vuetify component <${node.tag}> needs an explicit closing tag in Dashboard templates`)
140
+ }
141
+ const insideRepeat = repeated || hasDirective(node, ['for'])
142
+ const adapter = candidateAdapter(node)
143
+ const customAttribute = (node.props || []).some(prop =>
144
+ (prop.type === 6 && prop.name.startsWith('data-cp-')) ||
145
+ (prop.type === 7 && prop.name === 'bind' && prop.arg?.content?.startsWith('data-cp-')))
146
+ if (customAttribute) throw new Error('ControlPanel-specific data-cp-* attributes are no longer supported')
147
+ if (adapter) {
148
+ const component = COMPONENT_ADAPTERS[node.tag.toLowerCase()] ? node.tag.toLowerCase() : ''
149
+ const display = adapter === 'text-display'
150
+ ? displayModel(node)
151
+ : ['v-progress-linear', 'v-progress-circular'].includes(component) ? progressModel(node)
152
+ : adapter === 'switch' && node.tag.toLowerCase() === 'button'
153
+ ? pressedModel(node)
154
+ : inputModel(node, adapter === 'range' || adapter === 'select')
155
+ const source = format.slice(node.loc.start.offset, openingTagEnd(format, node.loc.start.offset) + 1)
156
+ const stableName = attribute(node, 'id') || attribute(node, 'name')
157
+ const locator = stableName
158
+ ? `name:${hash(node.tag + ':' + stableName)}`
159
+ : `tag:${hash(source.replace(/\s+/g, ' ').trim() +
160
+ (adapter === 'text-display' ? ':' + (display.model || node.children[0]?.content?.trim() || '') : ''))}`
161
+ const explicitLogic = hasDirective(node, ['on']) ||
162
+ (node.props || []).some(prop => prop.type === 7 && prop.name === 'bind' &&
163
+ ['value', 'checked', 'model-value', 'modelValue'].includes(prop.arg?.content) &&
164
+ !(['v-progress-linear', 'v-progress-circular'].includes(component) &&
165
+ ['model-value', 'modelValue'].includes(prop.arg?.content)))
166
+ const nestedLogic = component === 'v-radio-group' && hasNestedHandlers(node)
167
+ const unboundedNumber = node.tag.toLowerCase() === 'input' &&
168
+ attribute(node, 'type').toLowerCase() === 'number'
169
+ const min = staticNumericProp(node, 'min')
170
+ const max = staticNumericProp(node, 'max')
171
+ const step = staticNumericProp(node, 'step')
172
+ const dynamicBounds = (adapter === 'range' && (min.dynamic || max.dynamic || step.dynamic)) ||
173
+ (component === 'v-progress-linear' && max.dynamic)
174
+ const invalidSliderBounds = component === 'v-slider' &&
175
+ ((min.value ?? 0) >= (max.value ?? 100) ||
176
+ (step.value !== undefined && step.value <= 0) || attribute(node, 'step') === 'any')
177
+ const customSwitchValues = component && adapter === 'switch' &&
178
+ ((node.props || []).some(prop => prop.type === 6 &&
179
+ ['multiple', 'value', 'true-value', 'false-value'].includes(prop.name)) ||
180
+ hasBoundAttribute(node, ['multiple', 'value', 'true-value', 'false-value']))
181
+ const unsupportedSelect = component === 'v-select' &&
182
+ enabledBooleanProp(node, ['multiple', 'return-object', 'clearable'])
183
+ const unsupportedRadio = component === 'v-radio-group' &&
184
+ (hasAttribute(node, ['multiple', 'type']) || nestedLogic)
185
+ const unsupportedTextType = component === 'v-text-field' &&
186
+ ((attribute(node, 'type') && attribute(node, 'type') !== 'text') ||
187
+ hasBoundAttribute(node, ['type']))
188
+ const unsupportedProgress = component === 'v-progress-linear' &&
189
+ (enabledBooleanProp(node, ['indeterminate', 'clickable']) ||
190
+ hasAttribute(node, ['min', 'step']) ||
191
+ (max.value !== undefined && max.value <= 0))
192
+ const unsupportedCircular = component === 'v-progress-circular' &&
193
+ (enabledBooleanProp(node, ['indeterminate']) || hasAttribute(node, ['min', 'max', 'step']))
194
+ const candidate = {
195
+ locator, adapter,
196
+ ...(component ? { component } : {}),
197
+ label: attribute(node, 'aria-label') || attribute(node, 'label') || attribute(node, 'title') ||
198
+ stableName || display.model || (adapter === 'text-display' ? node.children[0]?.content?.trim() : '') || node.tag,
199
+ ...(display.model ? { model: display.model } : {}),
200
+ ...(adapter === 'range' || adapter === 'meter' ? {
201
+ min: ['v-progress-linear', 'v-progress-circular'].includes(component) ? 0
202
+ : min.value !== undefined ? min.value : unboundedNumber ? undefined : 0,
203
+ max: component === 'v-progress-circular' ? 100
204
+ : max.value !== undefined ? max.value : unboundedNumber ? undefined
205
+ : adapter === 'meter' && !component ? 1 : 100,
206
+ step: ['v-progress-linear', 'v-progress-circular'].includes(component) ? 'any'
207
+ : step.value !== undefined ? String(step.value) : attribute(node, 'step') ||
208
+ (adapter === 'meter' ? 'any' : '1')
209
+ } : {}),
210
+ ...(attribute(node, 'data-unit') ? { unit: attribute(node, 'data-unit') } : {}),
211
+ line: node.loc.start.line,
212
+ supported: !insideRepeat && !explicitLogic && display.valid && !dynamicBounds &&
213
+ !invalidSliderBounds &&
214
+ !customSwitchValues && !unsupportedSelect && !unsupportedRadio &&
215
+ !unsupportedTextType && !unsupportedProgress && !unsupportedCircular &&
216
+ (!component || Boolean(display.model)),
217
+ reason: insideRepeat ? 'Repeated controls need a stable item identity' :
218
+ explicitLogic ? 'Controls with existing handlers or reactive value bindings need a dedicated adapter' :
219
+ !display.valid ? (adapter === 'text-display' ? 'Dynamic text needs one writable data field' :
220
+ 'Only a simple Vue v-model field is supported') :
221
+ dynamicBounds ? 'Dynamic numeric bounds need a dedicated adapter' :
222
+ invalidSliderBounds ? 'Slider min, max, and step must form a valid range' :
223
+ customSwitchValues ? 'Custom switch values need a dedicated adapter' :
224
+ unsupportedSelect ? 'Only a single scalar Vuetify selection is supported' :
225
+ unsupportedRadio ? 'Radio groups need scalar values without separate handlers' :
226
+ unsupportedTextType ? 'Only text Vuetify fields are supported' :
227
+ unsupportedProgress ? 'Progress needs a determinate positive range' :
228
+ unsupportedCircular ? 'Circular progress uses a determinate 0-100 range' :
229
+ component && !display.model ? 'Vuetify controls need a simple Vue v-model field' : ''
230
+ }
231
+ candidates.push({ ...candidate, offset: openingTagEnd(format, node.loc.start.offset) })
232
+ counts.set(locator, (counts.get(locator) || 0) + 1)
233
+ }
234
+ for (const child of node.children || []) visit(child, insideRepeat)
235
+ return
236
+ }
237
+ for (const child of node.children || []) visit(child, repeated)
238
+ for (const branch of node.branches || []) visit(branch, repeated)
239
+ }
240
+
241
+ if (descriptor.template?.ast) visit(descriptor.template.ast)
242
+ for (const candidate of candidates) {
243
+ if (counts.get(candidate.locator) > 1) {
244
+ candidate.supported = false
245
+ candidate.reason = 'This element is ambiguous; give it a unique HTML id or name'
246
+ }
247
+ }
248
+ return { candidates, templateHash: hash(format) }
249
+ }
250
+
251
+ function instrumentTemplate (format) {
252
+ const inspected = inspectTemplate(format)
253
+ let rendered = format
254
+ for (const candidate of [...inspected.candidates].reverse()) {
255
+ const before = rendered.slice(0, candidate.offset)
256
+ const selfClosing = /\/\s*$/.test(before)
257
+ const insertAt = selfClosing ? before.lastIndexOf('/') : candidate.offset
258
+ rendered = rendered.slice(0, insertAt) + ` data-cp-auto="${candidate.locator}"` + rendered.slice(insertAt)
259
+ }
260
+ return {
261
+ format: rendered,
262
+ candidates: inspected.candidates.map(({ offset, ...candidate }) => candidate),
263
+ templateHash: inspected.templateHash
264
+ }
265
+ }
266
+
267
+ module.exports = { inspectTemplate, instrumentTemplate }
@@ -0,0 +1,127 @@
1
+ const fs = require('node:fs')
2
+ const { inspectTemplate } = require('./panel-template-instrumentation')
3
+ const { validatedVisualBindings } = require('./panel-visual-bindings')
4
+ const { listCatalogDevices, listCatalogTargets } = require('./control-catalog')
5
+ const { configuredRoutes } = require('./control-bound-protocol')
6
+ const { provisionManagedBridges } = require('./managed-native-bridge')
7
+ const { backupFlow, flowLocation, localDirectory } = require('./managed-bridge-admin')
8
+
9
+ function conflict (message) {
10
+ const error = new Error(message)
11
+ error.status = 409
12
+ return error
13
+ }
14
+
15
+ function adminUrl (localPort) {
16
+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
17
+ throw new Error('Local Automator port unavailable')
18
+ }
19
+ return `http://127.0.0.1:${localPort}/red/flows`
20
+ }
21
+
22
+ async function getSnapshot (localPort, fetcher = fetch) {
23
+ const response = await fetcher(adminUrl(localPort), {
24
+ headers: { 'Node-RED-API-Version': 'v2' },
25
+ signal: AbortSignal.timeout(10000)
26
+ })
27
+ if (!response.ok) throw new Error(`Automator Flow read failed (${response.status})`)
28
+ const snapshot = await response.json()
29
+ if (typeof snapshot.rev !== 'string' || !Array.isArray(snapshot.flows)) {
30
+ throw new Error('Automator did not return a versioned Flow')
31
+ }
32
+ return snapshot
33
+ }
34
+
35
+ function findPanel (snapshot, panelId) {
36
+ const panel = snapshot.flows.find(node => node.id === panelId)
37
+ if (!panel || panel.type !== 'ui-control-panel') {
38
+ throw conflict('Visual editor is not enabled for this panel')
39
+ }
40
+ if (String(panel.z || '').startsWith('subflow:') ||
41
+ snapshot.flows.some(node => node.id === panel.z && node.type === 'subflow')) {
42
+ throw conflict('Editing a shared subflow template from the page is not supported')
43
+ }
44
+ return panel
45
+ }
46
+
47
+ function editorConfig (snapshot, panelId) {
48
+ const panel = findPanel(snapshot, panelId)
49
+ const inspected = inspectTemplate(panel.format)
50
+ let bindings = panel.visualBindings || '{}'
51
+ if (typeof bindings === 'string') bindings = JSON.parse(bindings)
52
+ return {
53
+ rev: snapshot.rev,
54
+ templateHash: inspected.templateHash,
55
+ candidates: inspected.candidates,
56
+ bindings: Object.entries(bindings).map(([id, entry]) => ({ id, ...entry })),
57
+ room: panel.boundRoom || ''
58
+ }
59
+ }
60
+
61
+ async function applyVisualBindings ({
62
+ panelId, rev, templateHash, entries, userDir, remoteAddress, localPort,
63
+ catalogBase, fetcher = fetch
64
+ }) {
65
+ localDirectory(userDir, remoteAddress)
66
+ if (!catalogBase) throw conflict('Device catalog is unavailable')
67
+ const snapshot = await getSnapshot(localPort, fetcher)
68
+ if (snapshot.rev !== rev) throw conflict('Flow changed; refresh the visual editor')
69
+ const panel = findPanel(snapshot, panelId)
70
+ if (inspectTemplate(panel.format).templateHash !== templateHash) {
71
+ throw conflict('Template changed; refresh the visual editor')
72
+ }
73
+ const location = flowLocation(fs.realpathSync(userDir))
74
+ if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
75
+ throw conflict('Deployed Flow differs from the active project file')
76
+ }
77
+
78
+ const devices = await listCatalogDevices(catalogBase, fetcher)
79
+ if (!Array.isArray(entries) || entries.length > 100) throw conflict('Invalid binding draft')
80
+ const deviceIds = [...new Set(entries.map(entry => entry?.device))]
81
+ for (const id of deviceIds) {
82
+ if (!devices.some(device => device.id === id && device.online)) {
83
+ throw conflict(`Device is unavailable: ${id}`)
84
+ }
85
+ }
86
+ const targetsByDevice = new Map(await Promise.all(deviceIds.map(async id =>
87
+ [id, await listCatalogTargets(catalogBase, id, fetcher)])))
88
+ const validated = validatedVisualBindings({ format: panel.format, entries, devices, targetsByDevice })
89
+ const next = structuredClone(snapshot.flows)
90
+ const nextPanel = next.find(node => node.id === panelId)
91
+ nextPanel.visualBindings = JSON.stringify(validated)
92
+ nextPanel.nativeBridge = configuredRoutes(nextPanel).size > 0
93
+ const provisioned = provisionManagedBridges(next, { panelId })
94
+
95
+ const latest = await getSnapshot(localPort, fetcher)
96
+ if (latest.rev !== snapshot.rev) throw conflict('Flow changed during validation; refresh and retry')
97
+ if (JSON.stringify(JSON.parse(fs.readFileSync(location.flowFile, 'utf8'))) !== JSON.stringify(snapshot.flows)) {
98
+ throw conflict('Active project changed during validation')
99
+ }
100
+ const backupDir = backupFlow(location, snapshot.rev, snapshot.flows)
101
+ const response = await fetcher(adminUrl(localPort), {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ 'Node-RED-API-Version': 'v2',
106
+ 'Node-RED-Deployment-Type': 'nodes'
107
+ },
108
+ body: JSON.stringify({ rev: snapshot.rev, flows: provisioned.flows }),
109
+ signal: AbortSignal.timeout(20000)
110
+ })
111
+ if (!response.ok) throw conflict(`Automator rejected the binding update (${response.status})`)
112
+ const applied = await response.json().catch(() => ({}))
113
+ let confirmed
114
+ for (let attempt = 0; attempt < 5; attempt++) {
115
+ confirmed = await getSnapshot(localPort, fetcher)
116
+ const confirmedPanel = confirmed.flows.find(node => node.id === panelId)
117
+ if (confirmedPanel?.visualBindings === nextPanel.visualBindings &&
118
+ confirmedPanel.bridgeRevisionApplied === provisioned.flows.find(node => node.id === panelId)?.bridgeRevisionApplied) {
119
+ return { ok: true, rev: confirmed.rev, backupDir, updated: provisioned.updated }
120
+ }
121
+ if (confirmed.rev !== snapshot.rev && confirmed.rev !== applied.rev) break
122
+ if (attempt < 4) await new Promise(resolve => setTimeout(resolve, 200))
123
+ }
124
+ throw conflict('Automator did not confirm the binding update; refresh before retrying')
125
+ }
126
+
127
+ module.exports = { adminUrl, applyVisualBindings, editorConfig, getSnapshot }
@@ -0,0 +1,95 @@
1
+ const { inspectTemplate } = require('./panel-template-instrumentation')
2
+ const { compatibleTarget, controlTypeForAdapter, targetMeta } = require('./control-type-model')
3
+
4
+ const ID = /^[A-Za-z][A-Za-z0-9._/-]{0,127}$/
5
+ const MODES = new Set(['read', 'write', 'both'])
6
+
7
+ function parseVisualBindings (config) {
8
+ let raw = config.visualBindings || '{}'
9
+ if (typeof raw === 'string') raw = JSON.parse(raw)
10
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid visual bindings')
11
+ return raw
12
+ }
13
+
14
+ function visualRoutes (config) {
15
+ const saved = parseVisualBindings(config)
16
+ const candidates = inspectTemplate(config.format).candidates
17
+ const locators = new Map(candidates.filter(item => item.supported).map(item => [item.locator, item]))
18
+ const routes = new Map()
19
+ const usedLocators = new Set()
20
+ for (const [id, entry] of Object.entries(saved)) {
21
+ if (!ID.test(id) || !entry || typeof entry !== 'object' || Array.isArray(entry)) {
22
+ throw new Error(`Invalid visual control: ${id}`)
23
+ }
24
+ const candidate = locators.get(entry.locator)
25
+ if (!candidate || candidate.adapter !== entry.adapter) {
26
+ throw new Error(`Visual control moved or became ambiguous: ${id}`)
27
+ }
28
+ if (usedLocators.has(entry.locator)) throw new Error(`Duplicate visual control locator: ${id}`)
29
+ usedLocators.add(entry.locator)
30
+ if (!MODES.has(entry.mode) || !entry.type || !entry.label ||
31
+ (['meter', 'text-display'].includes(entry.adapter) && entry.mode !== 'read')) {
32
+ throw new Error(`Invalid visual binding settings: ${id}`)
33
+ }
34
+ routes.set(id, {
35
+ control: { id, type: entry.type, mode: entry.mode, label: entry.label,
36
+ adapter: entry.adapter, locator: entry.locator },
37
+ binding: entry
38
+ })
39
+ }
40
+ return routes
41
+ }
42
+
43
+ function validatedVisualBindings ({ format, entries, devices, targetsByDevice }) {
44
+ if (!Array.isArray(entries) || entries.length > 100) throw new Error('At most 100 visual bindings are supported')
45
+ const candidates = new Map(inspectTemplate(format).candidates
46
+ .filter(item => item.supported).map(item => [item.locator, item]))
47
+ const knownDevices = new Map(devices.map(device => [device.id, device]))
48
+ const result = Object.create(null)
49
+ const used = new Set()
50
+ const usedLocators = new Set()
51
+ for (const raw of entries) {
52
+ if (!raw || !ID.test(raw.id) || used.has(raw.id)) throw new Error('Control IDs must be unique and valid')
53
+ used.add(raw.id)
54
+ const candidate = candidates.get(raw.locator)
55
+ if (!candidate || candidate.adapter !== raw.adapter) throw new Error(`Control changed: ${raw.id}`)
56
+ if (usedLocators.has(raw.locator)) throw new Error(`One element cannot have two bindings: ${raw.id}`)
57
+ usedLocators.add(raw.locator)
58
+ if (!MODES.has(raw.mode) || (['meter', 'text-display'].includes(raw.adapter) && raw.mode !== 'read')) {
59
+ throw new Error(`Invalid binding mode: ${raw.id}`)
60
+ }
61
+ if (typeof raw.label !== 'string' || !raw.label.trim() || raw.label.length > 80) {
62
+ throw new Error(`Invalid control label: ${raw.id}`)
63
+ }
64
+ if (!knownDevices.has(raw.device)) throw new Error(`Unknown device: ${raw.id}`)
65
+ const targets = targetsByDevice.get(raw.device)
66
+ if (!targets) throw new Error(`Device catalog unavailable: ${raw.device}`)
67
+ const byPath = new Map(targets.map(target => [target.path, target]))
68
+ const write = raw.mode === 'read' ? null : byPath.get(raw.writePath)
69
+ const read = raw.mode === 'write' ? null : byPath.get(raw.readPath)
70
+ if ((raw.mode !== 'read' && !write) || (raw.mode !== 'write' && !read)) {
71
+ throw new Error(`Target disappeared: ${raw.id}`)
72
+ }
73
+ if (write && !compatibleTarget(raw.adapter, write, 'write', candidate)) throw new Error(`Incompatible write target: ${raw.id}`)
74
+ if (read && !compatibleTarget(raw.adapter, read, 'read', candidate)) throw new Error(`Incompatible feedback target: ${raw.id}`)
75
+ if (write && read && controlTypeForAdapter(raw.adapter, write) !== controlTypeForAdapter(raw.adapter, read)) {
76
+ throw new Error(`Read and write targets have different types: ${raw.id}`)
77
+ }
78
+ const target = write || read
79
+ const type = controlTypeForAdapter(raw.adapter, target)
80
+ result[raw.id] = {
81
+ locator: raw.locator, adapter: raw.adapter, type, label: raw.label.trim(), mode: raw.mode,
82
+ ...(candidate.model ? { model: candidate.model } : {}),
83
+ ...(candidate.component ? { component: candidate.component } : {}),
84
+ ...(['range', 'meter'].includes(raw.adapter)
85
+ ? { uiRange: { min: candidate.min, max: candidate.max, step: candidate.step } } : {}),
86
+ device: raw.device,
87
+ writePath: write?.path || '', readPath: read?.path || '',
88
+ ...(write ? { writeMeta: targetMeta(write, raw.onebased) } : {}),
89
+ ...(read ? { readMeta: targetMeta(read, raw.onebased) } : {})
90
+ }
91
+ }
92
+ return result
93
+ }
94
+
95
+ module.exports = { parseVisualBindings, visualRoutes, validatedVisualBindings }
package/room-gateway.html DELETED
@@ -1,21 +0,0 @@
1
- <script type="text/javascript">
2
- RED.nodes.registerType('room-gateway',{
3
- category:'Room Tools',color:'#a8d5c3',defaults:{name:{value:''},room:{value:'room-01',required:true},secureCookie:{value:false}},
4
- credentials:{username:{type:'text'},password:{type:'password'}},inputs:1,outputs:1,icon:'font-awesome/fa-lock',
5
- paletteLabel:'区域权限网关',label:function(){return this.name||'区域权限网关'},inputLabels:'设备反馈 kind=state',outputLabels:'已验证的控制指令'
6
- })
7
- </script>
8
- <script type="text/html" data-template-name="room-gateway">
9
- <div class="form-row"><label for="node-input-name">名称</label><input id="node-input-name" type="text"></div>
10
- <div class="form-row"><label for="node-input-room">区域 ID</label><input id="node-input-room" type="text"></div>
11
- <div class="form-row"><label for="node-input-username">登录账号</label><input id="node-input-username" type="text" autocomplete="off"></div>
12
- <div class="form-row"><label for="node-input-password">登录密码</label><input id="node-input-password" type="password" autocomplete="new-password"></div>
13
- <div class="form-row"><label for="node-input-secureCookie">HTTPS</label><input id="node-input-secureCookie" type="checkbox" style="width:auto">仅通过 HTTPS 发送会话 Cookie</div>
14
- <p>密码至少 12 个字符。每个区域 ID 只能部署一个网关,账号只能访问该网关配置的区域。</p>
15
- </script>
16
- <script type="text/html" data-help-name="room-gateway">
17
- <p>Template 通过同源 /room-panel-api/regions/&lt;区域 ID&gt; 登录和发送控制请求;ui-control-panel 登录后自动订阅该区域的实时设备反馈。Template 输出不再用于控制设备。</p>
18
- <p>本节点输出已授权消息,连接原来的 Switch。设备反馈必须含 room、kind: state、topic 和 payload,接入本节点输入,不接 Template。</p>
19
- <p>网关会保存每个已关联控件最后收到的真实设备状态。页面在线时通过实时反馈流立即推送;页面未登录时继续缓存,登录后在反馈流首帧发送完整状态快照。</p>
20
- <p>公开状态只包含设备反馈,不会把刚接受的控制目标冒充实际状态。Node-RED 编辑器属于管理员入口,部署时应独立保护。生产 iPad 访问请配置 HTTPS 并启用 HTTPS Cookie。</p>
21
- </script>