node-red-contrib-room-tools 1.8.8 → 1.8.15
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 +4 -4
- package/README.md +2 -2
- package/control-catalog.js +5 -3
- package/control-panel.html +17 -8
- package/control-panel.js +14 -10
- package/managed-bridge-revision.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/panel-flow-editor-sync.js +53 -0
- package/panel-http.js +24 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ControlPanel 前端页面编写指南
|
|
2
2
|
|
|
3
|
-
适用基线:`node-red-contrib-room-tools@1.8.
|
|
3
|
+
适用基线:`node-red-contrib-room-tools@1.8.9` 的 `ui-control-panel`,当前本地修订为 `node-red-contrib-room-tools@1.8.15`,Dashboard 2,Vue Options API。本文按当前代码审核,不表示支持任意 Vue、Vuetify 或第三方组件。
|
|
4
4
|
|
|
5
5
|
给 AI 编写页面前阅读本文。目标是:页面作者只写普通前端页面;部署人员在网页编辑模式选择设备与点位;ControlPanel 保存映射并负责设备命令、反馈和网页侧量程换算。
|
|
6
6
|
|
|
@@ -137,14 +137,14 @@ export default {
|
|
|
137
137
|
## 5. 部署与绑定
|
|
138
138
|
|
|
139
139
|
1. 将页面粘贴到 `ui-control-panel` Template,选择 Dashboard 页面,设置 Room ID、普通账号及独立的编辑账号。普通账号密码至少 12 个字符;账号密码由节点配置,不能写入 H5。保存并 Deploy。
|
|
140
|
-
2. 重新打开节点,点击 `Open
|
|
140
|
+
2. 重新打开节点,点击 `Open binding editor`,用编辑账号登录。Select 默认激活,可以点击、右键或触屏长按可绑定控件。`Open page` 打开普通控制页面,使用普通用户账号登录。
|
|
141
141
|
3. 选择在线设备、模式、Write point、Feedback point、编号方式,保存草稿。草稿和亮紫色边框不表示已部署;边框只在编辑模式显示。
|
|
142
|
-
4. 点 Apply。节点检查当前模板、目录参数和 Flow
|
|
142
|
+
4. 点 Apply。节点检查当前模板、目录参数和 Flow 版本,备份后替换该面板此前生成的桥接并保存。Apply 不替用户 Deploy;当 Automator 报告外部 Flow 变化时,编辑器只刷新一次 Flow 页面,之后由用户检查并手动 Deploy。不会无限追加旧桥接,也不会清除其他面板的绑定。Apply 成功后仍在编辑页,无需再次登录。
|
|
143
143
|
5. 点 Exit,回到普通页面登录页,再输入普通账号密码。普通账号不能进入编辑模式;不登录不能操作设备。进程重启或凭据变化可能要求重新登录。
|
|
144
144
|
|
|
145
145
|
Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 Apply。从列表进入 Edit,保存或取消后返回列表。刷新、离开或重启前先 Apply,避免草稿丢失。
|
|
146
146
|
|
|
147
|
-
普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply
|
|
147
|
+
普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply 的原有模板、目录、Flow 版本和输入校验保持不变;Flow 页面刷新不自动合并画布改动,也不自动 Deploy。
|
|
148
148
|
|
|
149
149
|
设备必须在线且当前运行环境的 AMX 目录可返回参数,才能查询或重新 Apply 相关绑定。已保存映射在 Flow 中,启动和显示页面不要求驱动在线;但断线时不能保证设备控制或新反馈。Mac 安装模块不等于 MUSE 已安装,Push 也不等于复制本地 node_modules。部署模块属于运行环境操作,不属于前端开发职责。
|
|
150
150
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ This package provides `ui-control-panel` for Node-RED and MUSE Automator.
|
|
|
4
4
|
|
|
5
5
|
## ControlPanel
|
|
6
6
|
|
|
7
|
-
The page author writes ordinary HTML/CSS/Vue and pastes it into the `ui-control-panel` Template. After configuring a Dashboard page, room ID, control login, and separate editor login, save and Deploy. Reopen the node and select **Open
|
|
7
|
+
The page author writes ordinary HTML/CSS/Vue and pastes it into the `ui-control-panel` Template. After configuring a Dashboard page, room ID, control login, and separate editor login, save and Deploy. Reopen the node and select **Open binding editor**. Sign in with the editor account, right-click a supported control, choose its device points, and Apply. **Open page** opens the normal control page with its separate user login.
|
|
8
8
|
|
|
9
9
|
Both logins work over HTTP on a trusted local network. HTTPS is recommended for other networks because HTTP transmits credentials without encryption. Session cookies are marked `Secure` when the connection uses HTTPS.
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@ Vuetify adapters cover `v-slider`, `v-switch`, `v-checkbox`, `v-select`, `v-radi
|
|
|
16
16
|
|
|
17
17
|
Use paired Vuetify tags in Dashboard templates, such as `<v-slider></v-slider>`; self-closing component tags can absorb the following controls during Dashboard rendering and are rejected before deployment.
|
|
18
18
|
|
|
19
|
-
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, generates the native bridge for that panel,
|
|
19
|
+
After clicking Apply in the page binding editor, the package validates the mapping, backs up the active Flow, and generates the native bridge for that panel using the original 1.8.9 flow. Apply does not click Deploy for the user. When Automator reports the external Flow change, the editor refreshes its Flow view once; review the refreshed Flow and deploy it yourself. The bridge revision must match the saved mapping before a command can reach a device. A Node-RED simulation verifies software behavior, while live device operation requires a separate check.
|
|
20
20
|
|
|
21
21
|
The current runtime has one panel-scoped control path. The retired topic-based region gateway, fixed demo scenes, hand-written control tags, and `sendControl` authoring API are not supported. The page editor and server use the same target compatibility rules.
|
|
22
22
|
|
package/control-catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { schemaVersion } = require('./control-type-contract.json')
|
|
2
|
+
const { runtimeJsonRequest } = require('./panel-http')
|
|
2
3
|
|
|
3
4
|
function catalogBase (settings) {
|
|
4
5
|
const port = Number(settings?.uiPort)
|
|
@@ -15,15 +16,16 @@ function catalogBase (settings) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
async function catalogRequest (base, path, device, fetcher = fetch) {
|
|
18
|
-
const options = {
|
|
19
|
+
const options = {}
|
|
19
20
|
if (device !== undefined) {
|
|
20
21
|
options.method = 'POST'
|
|
21
22
|
options.headers = { 'content-type': 'application/json' }
|
|
22
23
|
options.body = JSON.stringify([device])
|
|
23
24
|
}
|
|
24
|
-
const response = await
|
|
25
|
+
const { response, data } = await runtimeJsonRequest(new URL(path, base), options, {
|
|
26
|
+
fetcher, timeoutMs: 15000, operation: `Device catalog ${path}${device ? ' (' + device + ')' : ''}`
|
|
27
|
+
})
|
|
25
28
|
if (!response.ok) throw new Error(`Automator catalog HTTP ${response.status}`)
|
|
26
|
-
const data = await response.json()
|
|
27
29
|
if (data.code !== 0 || !Array.isArray(data.value)) throw new Error(data.reason || 'Invalid Automator catalog response')
|
|
28
30
|
return data.value
|
|
29
31
|
}
|
package/control-panel.html
CHANGED
|
@@ -229,9 +229,11 @@
|
|
|
229
229
|
})
|
|
230
230
|
|
|
231
231
|
$('#control-binding-auth').show()
|
|
232
|
-
|
|
232
|
+
const openDashboardPage = (editMode) => {
|
|
233
|
+
const scope = $templateScope.val() || that.templateScope || 'local'
|
|
233
234
|
const group = RED.nodes.node($('#node-input-group').val() || that.group)
|
|
234
|
-
const
|
|
235
|
+
const pageId = scope === 'local' ? group?.page : ($('#node-input-page').val() || that.page)
|
|
236
|
+
const page = RED.nodes.node(pageId)
|
|
235
237
|
const base = RED.nodes.node(page?.ui)
|
|
236
238
|
if (!page?.path) {
|
|
237
239
|
RED.notify('Choose a Dashboard page, then save and deploy this node.', { type: 'warning' })
|
|
@@ -239,9 +241,11 @@
|
|
|
239
241
|
}
|
|
240
242
|
const url = new URL((base?.path || '/dashboard').replace(/\/$/, '') + '/' +
|
|
241
243
|
page.path.replace(/^\//, ''), window.location.origin)
|
|
242
|
-
url.searchParams.set('cp-edit', that.id)
|
|
244
|
+
if (editMode) url.searchParams.set('cp-edit', that.id)
|
|
243
245
|
window.open(url.href, '_blank', 'noopener')
|
|
244
|
-
}
|
|
246
|
+
}
|
|
247
|
+
$('#control-page-open').on('click', () => openDashboardPage(false))
|
|
248
|
+
$('#control-visual-open').on('click', () => openDashboardPage(true))
|
|
245
249
|
RED.library.create({
|
|
246
250
|
url: 'uitemplates', // where to get the data from
|
|
247
251
|
type: 'ui-template', // the type of object the library is for
|
|
@@ -334,6 +338,11 @@
|
|
|
334
338
|
}
|
|
335
339
|
this.editor && this.editor.resize()
|
|
336
340
|
}
|
|
341
|
+
if (RED.comms?.subscribe) {
|
|
342
|
+
$.ajax({ url: 'ui-control-panel/editor-sync.js', dataType: 'script', cache: false })
|
|
343
|
+
.done(() => window.ControlPanelFlowEditorSync.install(RED, $, window))
|
|
344
|
+
.fail(() => RED.notify('ControlPanel automatic Flow sync could not be loaded.', 'warning'))
|
|
345
|
+
}
|
|
337
346
|
})()
|
|
338
347
|
</script>
|
|
339
348
|
|
|
@@ -350,7 +359,7 @@
|
|
|
350
359
|
<p>普通 HTML/Vue 控件由页面绑定编辑器自动识别,并通过面板的 authenticated session 发送控制。开发者不需要添加标签、调用自定义节点 API,或在 Template 中实现 throttling、latest-wins、队列和设备协议。</p>
|
|
351
360
|
<p>页面只需按普通 H5/Vue 方式编写控件;编辑模式下在控件上打开绑定菜单,选择设备参数即可。登录、发送、反馈和状态同步由节点负责,Template 不应主动查询设备或把反馈回发成命令。</p>
|
|
352
361
|
<p>See <code>CONTROL_PANEL_AI_GUIDE.md</code> in the node package for the complete AI authoring contract and examples.</p>
|
|
353
|
-
<p>Save and Deploy the panel, then use Open
|
|
362
|
+
<p>Save and Deploy the panel, then use Open binding editor. Apply in that page validates and saves bindings, backs up the Flow, and regenerates the native bridge. When Node-RED reports a newer external Flow revision, the Automator editor page reloads once; the panel never imports or deploys Flow data. Ordinary Deploy alone does not apply bindings. Check targets and numbering before using real hardware.</p>
|
|
354
363
|
</script>
|
|
355
364
|
|
|
356
365
|
<script type="text/html" data-template-name="ui-control-panel">
|
|
@@ -428,10 +437,10 @@
|
|
|
428
437
|
<div style="height:250px; min-height:100px" class="node-text-editor" id="node-input-format-editor" ></div>
|
|
429
438
|
</div>
|
|
430
439
|
<div class="form-row">
|
|
431
|
-
<label>Page
|
|
440
|
+
<label>Page</label>
|
|
432
441
|
<input type="hidden" id="node-input-visualBindings">
|
|
433
|
-
<button type="button" id="control-
|
|
434
|
-
<
|
|
442
|
+
<button type="button" id="control-page-open" class="red-ui-button">Open page</button>
|
|
443
|
+
<button type="button" id="control-visual-open" class="red-ui-button">Open binding editor</button>
|
|
435
444
|
</div>
|
|
436
445
|
<div class="form-row" style="display:none">
|
|
437
446
|
<input type="hidden" id="node-input-bridgeRevisionApplied">
|
package/control-panel.js
CHANGED
|
@@ -69,6 +69,10 @@ module.exports = function (RED) {
|
|
|
69
69
|
const base = catalogBase(RED.settings)
|
|
70
70
|
const activePanels = new Map()
|
|
71
71
|
const activeEditors = new Map()
|
|
72
|
+
RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
|
|
73
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
74
|
+
res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
|
|
75
|
+
})
|
|
72
76
|
RED.httpNode.use((req, res, next) => {
|
|
73
77
|
const path = new URL(req.url, 'http://localhost').pathname
|
|
74
78
|
if (path === '/control-panel-assets/panel-login-bg.png') {
|
|
@@ -194,16 +198,16 @@ module.exports = function (RED) {
|
|
|
194
198
|
if (!base) throw new Error('Device catalog unavailable')
|
|
195
199
|
return listCatalogTargets(base, device)
|
|
196
200
|
},
|
|
197
|
-
apply:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
201
|
+
apply: (req, body) => applyVisualBindings({
|
|
202
|
+
panelId: node.id,
|
|
203
|
+
rev: body.rev,
|
|
204
|
+
templateHash: body.templateHash,
|
|
205
|
+
entries: body.entries,
|
|
206
|
+
userDir: RED.settings.userDir,
|
|
207
|
+
localPort: req.socket.localPort,
|
|
208
|
+
adminRoot: RED.settings.httpAdminRoot ?? '/',
|
|
209
|
+
catalogBase: base
|
|
210
|
+
})
|
|
207
211
|
})
|
|
208
212
|
if (editor) activeEditors.set(node.id, editor)
|
|
209
213
|
else node.warn?.('Visual binding editor needs an editor username and password')
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.15",
|
|
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.
|
|
9
|
+
"version": "1.8.15",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@vue/compiler-sfc": "3.5.42",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-room-tools",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.15",
|
|
4
4
|
"description": "Authenticated visual control panel binding for Node-RED and MUSE Automator",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": { "node": ">=18.16.0" },
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"panel-visual-apply.js",
|
|
27
27
|
"panel-editor-auth.js",
|
|
28
28
|
"panel-http.js",
|
|
29
|
+
"panel-flow-editor-sync.js",
|
|
29
30
|
"panel-binding-editor.js",
|
|
30
31
|
"native-binding-plan.js",
|
|
31
32
|
"managed-native-bridge.js",
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof window === 'object') root.ControlPanelFlowEditorSync = factory()
|
|
3
|
+
else if (typeof module === 'object' && module.exports) module.exports = factory()
|
|
4
|
+
})(typeof window === 'object' ? window : this, function () {
|
|
5
|
+
const topic = 'notification/runtime-deploy'
|
|
6
|
+
|
|
7
|
+
// Node-RED already owns the Flow-changed notification. This listener only
|
|
8
|
+
// reloads the editor page after a newer runtime revision is announced. It
|
|
9
|
+
// must never read, clear, import, or deploy Flow data.
|
|
10
|
+
function createController (hooks) {
|
|
11
|
+
let stopped = false
|
|
12
|
+
let reloading = false
|
|
13
|
+
|
|
14
|
+
function request (message) {
|
|
15
|
+
if (stopped || reloading || !message || typeof message.revision !== 'string') return
|
|
16
|
+
const current = hooks.version()
|
|
17
|
+
// A retained notification received during page startup describes the
|
|
18
|
+
// Flow that is already loaded. Ignore it instead of causing a reload.
|
|
19
|
+
if (typeof current !== 'string' || current === message.revision) return
|
|
20
|
+
reloading = true
|
|
21
|
+
hooks.reload()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { request, stop: () => { stopped = true } }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function install (RED, $, host = window) {
|
|
28
|
+
if (RED.__controlPanelFlowSync) return RED.__controlPanelFlowSync
|
|
29
|
+
if (!RED.comms?.subscribe || !RED.nodes?.version || !host.location?.reload) {
|
|
30
|
+
RED.notify('ControlPanel Flow change listener is unavailable in this editor.', 'warning')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const controller = createController({
|
|
35
|
+
version: () => RED.nodes.version(),
|
|
36
|
+
reload: () => host.location.reload()
|
|
37
|
+
})
|
|
38
|
+
const changed = (_topic, message) => controller.request(message)
|
|
39
|
+
RED.comms.subscribe(topic, changed)
|
|
40
|
+
const instance = {
|
|
41
|
+
request: controller.request,
|
|
42
|
+
dispose () {
|
|
43
|
+
controller.stop()
|
|
44
|
+
RED.comms.unsubscribe(topic, changed)
|
|
45
|
+
delete RED.__controlPanelFlowSync
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
RED.__controlPanelFlowSync = instance
|
|
49
|
+
return instance
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { topic, createController, install }
|
|
53
|
+
})
|
package/panel-http.js
CHANGED
|
@@ -58,12 +58,34 @@ async function readJsonBody (req, maxBytes) {
|
|
|
58
58
|
// Serialized into the Vue component, so keep this function browser-only and self-contained.
|
|
59
59
|
async function fetchWithTimeout (url, options = {}, timeoutMs = 12000) {
|
|
60
60
|
const controller = new AbortController()
|
|
61
|
-
|
|
61
|
+
let timedOut = false
|
|
62
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
62
63
|
try {
|
|
63
64
|
return await fetch(url, { ...options, signal: controller.signal })
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (timedOut) throw new Error('Request timed out; your binding draft is still available')
|
|
67
|
+
throw error
|
|
64
68
|
} finally {
|
|
65
69
|
clearTimeout(timer)
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
// Keep the deadline active through JSON decoding, not just response headers.
|
|
74
|
+
async function runtimeJsonRequest (url, options, { fetcher = fetch, timeoutMs, operation }) {
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
let timedOut = false
|
|
77
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetcher(url, { ...options, signal: controller.signal })
|
|
80
|
+
if (!response.ok) return { response, data: null }
|
|
81
|
+
return { response, data: await response.json() }
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
const error = new Error(`${operation} ${timedOut ? 'timed out' : 'failed'}; ${timedOut ? 'the runtime did not respond in time' : cause.message}`, { cause })
|
|
84
|
+
error.status = 503
|
|
85
|
+
throw error
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { sameOrigin, replyJson, readJsonBody, fetchWithTimeout, runtimeJsonRequest }
|