node-red-contrib-modbus-modpackqt 2.1.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/DISCLAIMER.md +3 -6
- package/README.md +56 -21
- package/nodes/lib/probe-runtime.js +204 -0
- package/nodes/modpackqt-config.html +5 -8
- package/nodes/modpackqt-config.js +4 -35
- package/nodes/modpackqt-master-probe.html +103 -0
- package/nodes/modpackqt-master-probe.js +116 -0
- package/nodes/modpackqt-master-read.html +0 -2
- package/nodes/modpackqt-slave-probe.html +91 -0
- package/nodes/modpackqt-slave-probe.js +111 -0
- package/nodes/modpackqt-traffic.html +1 -1
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ All notable changes to **node-red-contrib-modbus-modpackqt** are documented
|
|
|
4
4
|
here. This project follows [Semantic Versioning](https://semver.org/) — pin a
|
|
5
5
|
major version (`^2.0.0`) in production.
|
|
6
6
|
|
|
7
|
+
## [3.1.0] — 2026-05-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Two new probe nodes** for live commissioning & analysis:
|
|
12
|
+
- `modpackqt-master-probe` — drop one per Modbus TCP device, click
|
|
13
|
+
**Open in ModPackQT Console** to launch the web tester live-attached
|
|
14
|
+
to that connection.
|
|
15
|
+
- `modpackqt-slave-probe` — drop one per fake slave (port + unit ID),
|
|
16
|
+
click the same button to open the web register editor.
|
|
17
|
+
- **Hidden HTTP runtime** (`nodes/lib/probe-runtime.js`) auto-starts on
|
|
18
|
+
`127.0.0.1:8502` when the first probe deploys, auto-stops when the last
|
|
19
|
+
unregisters. Exposes `/api/probes`, `/api/probes/:id/read|write|store`
|
|
20
|
+
for the modpackqt.com web console to drive.
|
|
21
|
+
- **Multi-device aggregation** — all probes from this Node-RED instance
|
|
22
|
+
appear together in the web console sidebar; switch between devices
|
|
23
|
+
with one click.
|
|
24
|
+
- **Editor admin route** `/modpackqt-probe/info` returns the runtime
|
|
25
|
+
host/port so the "Open in Console" button can deep-link with the
|
|
26
|
+
correct probe ID.
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
|
|
30
|
+
- Default bind is loopback-only (`127.0.0.1`) for safety. Override with
|
|
31
|
+
env vars `MODPACKQT_PROBE_HOST` and `MODPACKQT_PROBE_PORT` (e.g.
|
|
32
|
+
`MODPACKQT_PROBE_HOST=0.0.0.0` to allow remote browsers on your LAN).
|
|
33
|
+
- Port `8502` falls back to `8503`–`8506` if already in use.
|
|
34
|
+
- Probe nodes have **no flow inputs/outputs** — they're commissioning
|
|
35
|
+
tools, not flow nodes. Use the existing `master-read`/`-write`/
|
|
36
|
+
`slave-read`/`-write` nodes to wire Modbus into your flow logic.
|
|
37
|
+
- CORS is open to `https://modpackqt.com` plus common localhost dev
|
|
38
|
+
origins.
|
|
39
|
+
|
|
40
|
+
## [3.0.0] — 2026-05-10
|
|
41
|
+
|
|
42
|
+
### ⚠️ Breaking changes (positioning, not behavior)
|
|
43
|
+
|
|
44
|
+
- **Removed the 1,000 ops/day rate limit.** All nodes are now 100% free with
|
|
45
|
+
no usage limits. The `RATE_LIMIT` error code is no longer thrown. If your
|
|
46
|
+
flows previously caught it, you can remove that handler.
|
|
47
|
+
- **Removed the `modpackqt · ` brand prefix from node status text.** Status
|
|
48
|
+
text is now clean by default.
|
|
49
|
+
- **Repositioned palette as "Modbus commissioning, testing & analysis tools."**
|
|
50
|
+
README, package description and node help text updated. No code changes
|
|
51
|
+
beyond the rate-limit removal.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- `modpackqt-config.js` — `checkLimit()` is now a no-op counter. The API key
|
|
56
|
+
field is retained but is reserved for future optional cloud features
|
|
57
|
+
(profile sync, remote console). All Modbus functionality works without a key.
|
|
58
|
+
- README — pricing table, "free tier" sections and trial-key call-outs removed.
|
|
59
|
+
- DISCLAIMER.md — rate-limit references removed.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- New keywords: `modbus-tester`, `modbus-commissioning`, `modbus-analyzer`
|
|
64
|
+
for better discoverability.
|
|
65
|
+
|
|
7
66
|
## [2.1.1] — 2026-05-09
|
|
8
67
|
|
|
9
68
|
### Changed
|
package/DISCLAIMER.md
CHANGED
|
@@ -53,8 +53,6 @@ and a wrong write can damage equipment, ruin a batch, or cause a safety incident
|
|
|
53
53
|
- It does **not** guarantee delivery, ordering, or completeness of writes
|
|
54
54
|
beyond what the underlying `modbus-serial` library and the network/serial
|
|
55
55
|
link provide.
|
|
56
|
-
- The free tier is rate-limited; if you exceed 1,000 ops/day, master read/write
|
|
57
|
-
nodes will return errors. **Do not rely on the free tier for production.**
|
|
58
56
|
|
|
59
57
|
## Reporting issues
|
|
60
58
|
|
|
@@ -79,10 +77,9 @@ We strongly recommend subscribing to release notifications:
|
|
|
79
77
|
|
|
80
78
|
## Licence
|
|
81
79
|
|
|
82
|
-
This software is provided under the MIT License — see `LICENSE`.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
users. See <https://modpackqt.com/nodered/terms> for the full terms.
|
|
80
|
+
This software is provided under the MIT License — see `LICENSE`. The
|
|
81
|
+
limitation of liability in the LICENSE applies to all users. See
|
|
82
|
+
<https://modpackqt.com/nodered/terms> for the full terms.
|
|
86
83
|
|
|
87
84
|
---
|
|
88
85
|
|
package/README.md
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# node-red-contrib-modbus-modpackqt
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Modbus commissioning, testing & analysis tools for Node-RED.**
|
|
4
4
|
By [ModPackQT](https://modpackqt.com).
|
|
5
5
|
|
|
6
6
|
[](https://www.npmjs.com/package/node-red-contrib-modbus-modpackqt)
|
|
7
7
|
[](https://nodered.org)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
|
|
10
|
+
> **100% free, MIT-licensed, no usage limits.** Open the [ModPackQT web console](https://modpackqt.com) for register decoding, device simulation and AI assistance.
|
|
11
|
+
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
## What you get
|
|
13
15
|
|
|
14
16
|
- **Modbus master** — read (FC1–FC4) and write (FC5/FC6/FC15/FC16) over **TCP** or **RTU (serial)**
|
|
15
17
|
- **Embedded Modbus TCP slave server** — push values from any flow, let PLCs / SCADA / HMIs read them
|
|
16
|
-
- **
|
|
18
|
+
- **Passive traffic monitor** — see every Modbus op (timing, values, errors) in real time
|
|
17
19
|
- **Outputs raw register values** — pair with [`node-red-contrib-bytes-modpackqt`](https://www.npmjs.com/package/node-red-contrib-bytes-modpackqt) to decode int / float / string / bitmask
|
|
18
20
|
- **Zero external dependencies** — Modbus runs inside the Node-RED process
|
|
19
21
|
|
|
@@ -82,7 +84,7 @@ Drop a **modbus traffic** node anywhere on the canvas, point it at the same runt
|
|
|
82
84
|
}
|
|
83
85
|
```
|
|
84
86
|
|
|
85
|
-
Filter by direction, function code, or target if you only want a slice.
|
|
87
|
+
Filter by direction, function code, or target if you only want a slice.
|
|
86
88
|
|
|
87
89
|
### 4. Be a Modbus slave (let SCADA read your values)
|
|
88
90
|
|
|
@@ -137,30 +139,65 @@ External masters connecting to `your-host:1502`, unit `1`, FC `3`, address `0`,
|
|
|
137
139
|
|
|
138
140
|
| Node | Purpose |
|
|
139
141
|
|---|---|
|
|
140
|
-
| `modpackqt-config` | Shared runtime — master mode (TCP/RTU), serial settings, optional slave server
|
|
142
|
+
| `modpackqt-config` | Shared runtime — master mode (TCP/RTU), serial settings, optional slave server |
|
|
141
143
|
| `modpackqt-master-read` | Read FC1/FC2/FC3/FC4 from a remote Modbus device |
|
|
142
144
|
| `modpackqt-master-write` | Write FC5/FC6/FC15/FC16 to a remote Modbus device |
|
|
143
145
|
| `modpackqt-slave-read` | Read from the embedded slave's register store (verify what masters see) |
|
|
144
146
|
| `modpackqt-slave-write` | Push values into the embedded slave's register store |
|
|
145
|
-
| `modpackqt-traffic` |
|
|
147
|
+
| `modpackqt-traffic` | Passive monitor — emits one message per Modbus op with full visibility into what's happening on the wire |
|
|
148
|
+
| `modpackqt-master-probe` | **Live commissioning probe** for a single Modbus TCP device. One node per device. Click **Open in ModPackQT Console** to launch the web tester live-attached. |
|
|
149
|
+
| `modpackqt-slave-probe` | **Live simulator probe** for a single fake slave. One node per port. Web console gives you a real-time register editor. |
|
|
146
150
|
|
|
147
151
|
---
|
|
148
152
|
|
|
149
|
-
##
|
|
153
|
+
## Live commissioning with probe nodes
|
|
154
|
+
|
|
155
|
+
The two **probe** nodes (`modpackqt-master-probe`, `modpackqt-slave-probe`) are tools for the kind of work you do once per device — figuring out a register map, decoding bytes correctly, simulating a device for SCADA development. They have no flow inputs or outputs; instead, each probe registers itself with a small local HTTP runtime, and the [modpackqt.com web console](https://modpackqt.com) attaches live.
|
|
156
|
+
|
|
157
|
+
### Pattern: one probe per device
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
|
|
161
|
+
│ master-probe │ │ master-probe │ │ slave-probe │
|
|
162
|
+
│ Inverter A │ │ Energy Meter │ │ Fake Inverter │
|
|
163
|
+
│ 192.168.1.10:502 #1 │ │ 192.168.1.20:502 #5 │ │ Listening :1502 #1 │
|
|
164
|
+
│ [ Open in Console ] │ │ [ Open in Console ] │ │ [ Open in Console ] │
|
|
165
|
+
└─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Click **Open in Console** on any probe → the web console opens with **all probes from this Node-RED instance in the sidebar**, the clicked one pre-selected. Switch between devices with one click.
|
|
169
|
+
|
|
170
|
+
### Hidden runtime
|
|
150
171
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
|
154
|
-
|
|
155
|
-
|
|
|
156
|
-
|
|
|
157
|
-
|
|
|
158
|
-
|
|
|
159
|
-
|
|
|
172
|
+
The first probe deployed starts a small HTTP server on `127.0.0.1:8502`:
|
|
173
|
+
|
|
174
|
+
| Endpoint | Purpose |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `GET /api/health` | Runtime health + probe count |
|
|
177
|
+
| `GET /api/probes` | List all registered probes |
|
|
178
|
+
| `GET /api/probes/:id` | Probe details |
|
|
179
|
+
| `POST /api/probes/:id/read` | Master probe: read registers `{ fc, address, quantity }` |
|
|
180
|
+
| `POST /api/probes/:id/write` | Master probe: write registers `{ fc, address, values }` |
|
|
181
|
+
| `GET /api/probes/:id/store?type=&address=&quantity=` | Slave probe: inspect register values |
|
|
182
|
+
| `PUT /api/probes/:id/store` | Slave probe: set register values |
|
|
183
|
+
|
|
184
|
+
The server **auto-stops** when the last probe is removed.
|
|
185
|
+
|
|
186
|
+
### Network access
|
|
187
|
+
|
|
188
|
+
Default bind is **loopback-only** (`127.0.0.1`) — only browsers on the same machine can reach it. To allow remote browsers on your LAN:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
MODPACKQT_PROBE_HOST=0.0.0.0 MODPACKQT_PROBE_PORT=8502 node-red
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Port `8502` falls back to `8503`–`8506` automatically if it's already in use.
|
|
195
|
+
|
|
196
|
+
---
|
|
160
197
|
|
|
161
|
-
|
|
198
|
+
## Why this palette?
|
|
162
199
|
|
|
163
|
-
|
|
200
|
+
These nodes are built for **commissioning, testing, and analysis** — the kind of work where you need to quickly probe a device, verify register layouts, decode bytes correctly, simulate a slave for SCADA development, or watch traffic to debug a flaky link. Pair them with the [ModPackQT web console](https://modpackqt.com) for AI-assisted register decoding, profile management, and a full visual tester.
|
|
164
201
|
|
|
165
202
|
---
|
|
166
203
|
|
|
@@ -178,7 +215,6 @@ Make sure `node-red-contrib-bytes-modpackqt` is also installed before importing
|
|
|
178
215
|
|
|
179
216
|
| Issue | Solution |
|
|
180
217
|
|---|---|
|
|
181
|
-
| `ModPackQT free tier limit reached` | Either wait until midnight or [get a free trial key](https://modpackqt.com/nodered) |
|
|
182
218
|
| Decoded float looks like garbage | Try a different word order (`BE` ↔ `LE_SWAP`) — common conventions are ABCD and CDAB |
|
|
183
219
|
| `Serial port not configured for RTU mode` | Open runtime config → set Serial Port (e.g. `/dev/ttyUSB0` or `COM3`) |
|
|
184
220
|
| `EADDRINUSE` on slave port | Another process already uses that port. Pick a different one (e.g. `1502`). |
|
|
@@ -194,10 +230,9 @@ Make sure `node-red-contrib-bytes-modpackqt` is also installed before importing
|
|
|
194
230
|
- **Security issues:** report privately via the [security page](https://modpackqt.com/security).
|
|
195
231
|
- **Updates are never automatic.** Node-RED's palette manager will show
|
|
196
232
|
"update available" when we publish a new version — you choose when to
|
|
197
|
-
upgrade. Pin a major version (`^
|
|
233
|
+
upgrade. Pin a major version (`^3.0.0`) for stability.
|
|
198
234
|
- **Changelog:** the `CHANGELOG.md` file is shipped inside this package. We
|
|
199
235
|
follow [semver](https://semver.org/) — patch releases for bug fixes only.
|
|
200
|
-
- **Paid customers** get notice for security and breaking-change releases.
|
|
201
236
|
|
|
202
237
|
---
|
|
203
238
|
|
|
@@ -212,4 +247,4 @@ Make sure `node-red-contrib-bytes-modpackqt` is also installed before importing
|
|
|
212
247
|
|
|
213
248
|
## License & disclaimer
|
|
214
249
|
|
|
215
|
-
MIT — © ModPackQT. Provided **"as is" without warranty of any kind**. You are responsible for validating this software in your environment before any
|
|
250
|
+
MIT — © ModPackQT. Provided **"as is" without warranty of any kind**. You are responsible for validating this software in your environment before any use with real equipment.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModPackQT Probe Runtime — singleton HTTP server that exposes all probe
|
|
3
|
+
* nodes from this Node-RED instance to the modpackqt.com web console.
|
|
4
|
+
*
|
|
5
|
+
* Auto-starts when the first probe registers, auto-stops when the last
|
|
6
|
+
* unregisters. Default bind: 127.0.0.1:8502 (localhost-only, safe default).
|
|
7
|
+
* Override via env vars MODPACKQT_PROBE_HOST and MODPACKQT_PROBE_PORT.
|
|
8
|
+
*
|
|
9
|
+
* API:
|
|
10
|
+
* GET /api/health
|
|
11
|
+
* GET /api/probes
|
|
12
|
+
* GET /api/probes/:id
|
|
13
|
+
* POST /api/probes/:id/read { fc, address, quantity }
|
|
14
|
+
* POST /api/probes/:id/write { fc, address, values }
|
|
15
|
+
* GET /api/probes/:id/store?type=&address=&quantity=
|
|
16
|
+
* PUT /api/probes/:id/store { type, address, values }
|
|
17
|
+
*/
|
|
18
|
+
const http = require('http');
|
|
19
|
+
const { URL } = require('url');
|
|
20
|
+
|
|
21
|
+
const PALETTE_VERSION = '3.1.0';
|
|
22
|
+
const DEFAULT_PORT = parseInt(process.env.MODPACKQT_PROBE_PORT, 10) || 8502;
|
|
23
|
+
const BIND_HOST = process.env.MODPACKQT_PROBE_HOST || '127.0.0.1';
|
|
24
|
+
const PORT_RETRY = 5;
|
|
25
|
+
|
|
26
|
+
const ALLOWED_ORIGINS = [
|
|
27
|
+
'https://modpackqt.com',
|
|
28
|
+
'https://www.modpackqt.com',
|
|
29
|
+
'http://localhost:5000',
|
|
30
|
+
'http://localhost:5173',
|
|
31
|
+
'http://127.0.0.1:5000',
|
|
32
|
+
'http://127.0.0.1:5173'
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
class ProbeRuntime {
|
|
36
|
+
constructor() {
|
|
37
|
+
this.probes = new Map();
|
|
38
|
+
this.server = null;
|
|
39
|
+
this.actualHost = BIND_HOST;
|
|
40
|
+
this.actualPort = null;
|
|
41
|
+
this._adminRoutesRegistered = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
register(probe) {
|
|
45
|
+
this.probes.set(probe.id, probe);
|
|
46
|
+
if (!this.server) this._start();
|
|
47
|
+
return { host: this.actualHost, port: this.actualPort };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
unregister(probeId) {
|
|
51
|
+
if (!this.probes.has(probeId)) return;
|
|
52
|
+
this.probes.delete(probeId);
|
|
53
|
+
if (this.probes.size === 0) this._stop();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ensureAdminRoutes(RED) {
|
|
57
|
+
if (this._adminRoutesRegistered) return;
|
|
58
|
+
this._adminRoutesRegistered = true;
|
|
59
|
+
const self = this;
|
|
60
|
+
RED.httpAdmin.get(
|
|
61
|
+
'/modpackqt-probe/info',
|
|
62
|
+
RED.auth.needsPermission('flows.read'),
|
|
63
|
+
function (_req, res) {
|
|
64
|
+
res.json({
|
|
65
|
+
host: self.actualHost,
|
|
66
|
+
port: self.actualPort,
|
|
67
|
+
probes: self.probes.size,
|
|
68
|
+
version: PALETTE_VERSION
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_start() {
|
|
75
|
+
this.server = http.createServer((req, res) => this._handle(req, res));
|
|
76
|
+
const tryPort = (port, attemptsLeft) => {
|
|
77
|
+
const onError = (err) => {
|
|
78
|
+
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.warn(`[modpackqt] probe runtime: port ${port} in use, trying ${port + 1}`);
|
|
81
|
+
this.server.removeListener('error', onError);
|
|
82
|
+
tryPort(port + 1, attemptsLeft - 1);
|
|
83
|
+
} else {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.error(`[modpackqt] probe runtime failed to start: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
this.server.once('error', onError);
|
|
89
|
+
this.server.listen(port, BIND_HOST, () => {
|
|
90
|
+
this.actualPort = port;
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.log(`[modpackqt] probe runtime listening on http://${BIND_HOST}:${port}`);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
tryPort(DEFAULT_PORT, PORT_RETRY);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_stop() {
|
|
99
|
+
if (this.server) {
|
|
100
|
+
try { this.server.close(); } catch (_) { /* ignore */ }
|
|
101
|
+
this.server = null;
|
|
102
|
+
this.actualPort = null;
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.log('[modpackqt] probe runtime stopped (no probes registered)');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _handle(req, res) {
|
|
109
|
+
const origin = req.headers.origin;
|
|
110
|
+
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
111
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
112
|
+
res.setHeader('Vary', 'Origin');
|
|
113
|
+
}
|
|
114
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
115
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Gateway-Id, X-User-Id');
|
|
116
|
+
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const u = new URL(req.url, `http://${this.actualHost}:${this.actualPort}`);
|
|
120
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
121
|
+
|
|
122
|
+
if (parts[0] !== 'api') return this._json(res, 404, { error: 'not found' });
|
|
123
|
+
|
|
124
|
+
// GET /api/health
|
|
125
|
+
if (req.method === 'GET' && parts[1] === 'health' && !parts[2]) {
|
|
126
|
+
return this._json(res, 200, {
|
|
127
|
+
ok: true, version: PALETTE_VERSION, probes: this.probes.size
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// GET /api/probes
|
|
132
|
+
if (req.method === 'GET' && parts[1] === 'probes' && !parts[2]) {
|
|
133
|
+
const list = Array.from(this.probes.values()).map((p) => p.describe());
|
|
134
|
+
return this._json(res, 200, list);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// /api/probes/:id[/...]
|
|
138
|
+
if (parts[1] === 'probes' && parts[2]) {
|
|
139
|
+
const probe = this.probes.get(parts[2]);
|
|
140
|
+
if (!probe) return this._json(res, 404, { error: `probe not found: ${parts[2]}` });
|
|
141
|
+
|
|
142
|
+
const action = parts[3] || '';
|
|
143
|
+
const body = (req.method === 'POST' || req.method === 'PUT')
|
|
144
|
+
? await this._readBody(req) : {};
|
|
145
|
+
|
|
146
|
+
if (req.method === 'GET' && action === '') {
|
|
147
|
+
return this._json(res, 200, probe.describe(true));
|
|
148
|
+
}
|
|
149
|
+
if (req.method === 'POST' && action === 'read') {
|
|
150
|
+
const result = await probe.handleRead(body);
|
|
151
|
+
return this._json(res, 200, result);
|
|
152
|
+
}
|
|
153
|
+
if (req.method === 'POST' && action === 'write') {
|
|
154
|
+
const result = await probe.handleWrite(body);
|
|
155
|
+
return this._json(res, 200, result);
|
|
156
|
+
}
|
|
157
|
+
if (req.method === 'GET' && action === 'store') {
|
|
158
|
+
const result = await probe.handleStoreGet(u.searchParams);
|
|
159
|
+
return this._json(res, 200, result);
|
|
160
|
+
}
|
|
161
|
+
if (req.method === 'PUT' && action === 'store') {
|
|
162
|
+
const result = await probe.handleStorePut(body);
|
|
163
|
+
return this._json(res, 200, result);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this._json(res, 404, { error: 'not found' });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this._json(res, 500, { error: err && err.message ? err.message : String(err) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_json(res, status, payload) {
|
|
174
|
+
res.statusCode = status;
|
|
175
|
+
res.setHeader('Content-Type', 'application/json');
|
|
176
|
+
res.end(JSON.stringify(payload));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_readBody(req) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const chunks = [];
|
|
182
|
+
let total = 0;
|
|
183
|
+
req.on('data', (c) => {
|
|
184
|
+
total += c.length;
|
|
185
|
+
if (total > 1024 * 1024) { reject(new Error('payload too large')); req.destroy(); return; }
|
|
186
|
+
chunks.push(c);
|
|
187
|
+
});
|
|
188
|
+
req.on('end', () => {
|
|
189
|
+
if (chunks.length === 0) return resolve({});
|
|
190
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
191
|
+
catch (_) { reject(new Error('invalid JSON body')); }
|
|
192
|
+
});
|
|
193
|
+
req.on('error', reject);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let instance = null;
|
|
199
|
+
function getRuntime() {
|
|
200
|
+
if (!instance) instance = new ProbeRuntime();
|
|
201
|
+
return instance;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { getRuntime, PALETTE_VERSION };
|
|
@@ -102,15 +102,14 @@
|
|
|
102
102
|
<input type="number" id="node-config-input-slavePort" placeholder="1502">
|
|
103
103
|
</div>
|
|
104
104
|
|
|
105
|
-
<h4 style="margin-top:18px">API Key (optional — for
|
|
105
|
+
<h4 style="margin-top:18px">API Key (optional — reserved for future cloud features)</h4>
|
|
106
106
|
<div class="form-row">
|
|
107
107
|
<label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
|
|
108
|
-
<input type="password" id="node-config-input-apiKey" placeholder="
|
|
108
|
+
<input type="password" id="node-config-input-apiKey" placeholder="Optional — leave blank for local-only use">
|
|
109
109
|
</div>
|
|
110
110
|
|
|
111
111
|
<div class="form-tips">
|
|
112
|
-
<b>No extra app needed.</b> Modbus runs inside Node-RED
|
|
113
|
-
<b>Free tier:</b> 1,000 ops/day. <a href="https://modpackqt.com/nodered" target="_blank">Get unlimited →</a>
|
|
112
|
+
<b>No extra app needed.</b> Modbus runs inside Node-RED. 100% free, no usage limits.
|
|
114
113
|
</div>
|
|
115
114
|
<div class="form-row" style="margin-top:18px;padding-top:14px;border-top:1px solid #e5e7eb;text-align:center;">
|
|
116
115
|
<a href="https://modpackqt.com" target="_blank" rel="noopener noreferrer"
|
|
@@ -146,9 +145,7 @@
|
|
|
146
145
|
|
|
147
146
|
<h3>API key (optional)</h3>
|
|
148
147
|
<p>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Add a key for unlimited use and clean status text — <a href="https://modpackqt.com/nodered" target="_blank">get a free 30-day trial key →</a>.
|
|
152
|
-
No credit card required.
|
|
148
|
+
Reserved for future cloud features (profile sync, remote console). Leave blank
|
|
149
|
+
for local-only use — all Modbus functionality works without a key.
|
|
153
150
|
</p>
|
|
154
151
|
</script>
|
|
@@ -4,19 +4,11 @@
|
|
|
4
4
|
* Owns:
|
|
5
5
|
* - A connection pool of Modbus TCP/RTU master clients (one per target).
|
|
6
6
|
* - An optional embedded Modbus TCP slave server with a local register store.
|
|
7
|
-
* - A local rate-limit counter (anonymous tier: 1,000 ops/day per Node-RED instance).
|
|
8
|
-
*
|
|
9
|
-
* Phase 1: limits are enforced locally only. Phase 2 will validate the API key
|
|
10
|
-
* against the ModPackQT cloud lease endpoint for unlimited usage.
|
|
11
7
|
*/
|
|
12
8
|
module.exports = function (RED) {
|
|
13
9
|
const ModbusRTU = require('modbus-serial');
|
|
14
10
|
const { EventEmitter } = require('events');
|
|
15
11
|
|
|
16
|
-
const FREE_DAILY_LIMIT = 1000;
|
|
17
|
-
const UPGRADE_URL = 'https://modpackqt.com/nodered';
|
|
18
|
-
const DEBUG_LOG_INTERVAL = 100; // log "powered by ModPackQT" every N ops
|
|
19
|
-
|
|
20
12
|
function ModPackQTConfigNode(config) {
|
|
21
13
|
RED.nodes.createNode(this, config);
|
|
22
14
|
const node = this;
|
|
@@ -42,46 +34,24 @@ module.exports = function (RED) {
|
|
|
42
34
|
node.slavePort = parseInt(config.slavePort, 10) || 1502;
|
|
43
35
|
node.slaveHost = config.slaveHost || '0.0.0.0';
|
|
44
36
|
|
|
45
|
-
// ----
|
|
37
|
+
// ---- Optional API key (reserved for future cloud features) ----
|
|
46
38
|
node.apiKey = (node.credentials && node.credentials.apiKey) || '';
|
|
47
39
|
|
|
48
40
|
// ====================================================================
|
|
49
|
-
//
|
|
41
|
+
// OPS COUNTER (informational only — no limit enforced)
|
|
50
42
|
// ====================================================================
|
|
51
43
|
const today = () => new Date().toISOString().slice(0, 10);
|
|
52
44
|
node._opsDay = today();
|
|
53
45
|
node._opsCount = 0;
|
|
54
46
|
|
|
55
47
|
node.checkLimit = function () {
|
|
56
|
-
// Reset counter at midnight
|
|
57
48
|
const d = today();
|
|
58
49
|
if (d !== node._opsDay) { node._opsDay = d; node._opsCount = 0; }
|
|
59
|
-
|
|
60
|
-
// Phase 1: anonymous tier only — API key currently ignored for billing
|
|
61
|
-
if (node._opsCount >= FREE_DAILY_LIMIT) {
|
|
62
|
-
const err = new Error(
|
|
63
|
-
`ModPackQT free tier limit reached (${FREE_DAILY_LIMIT} ops/day). ` +
|
|
64
|
-
`Get a free trial API key at ${UPGRADE_URL}`
|
|
65
|
-
);
|
|
66
|
-
err.code = 'RATE_LIMIT';
|
|
67
|
-
throw err;
|
|
68
|
-
}
|
|
69
50
|
node._opsCount += 1;
|
|
70
|
-
|
|
71
|
-
if (node._opsCount % DEBUG_LOG_INTERVAL === 0) {
|
|
72
|
-
node.log(
|
|
73
|
-
`[modpackqt] ${node._opsCount} ops served today — ` +
|
|
74
|
-
`unlock unlimited at ${UPGRADE_URL}`
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
51
|
return node._opsCount;
|
|
78
52
|
};
|
|
79
53
|
|
|
80
|
-
node.brandStatus = function (text) {
|
|
81
|
-
// Phase 1: branding always visible (anonymous tier).
|
|
82
|
-
// Phase 2: paid users get clean text without the "modpackqt ·" prefix.
|
|
83
|
-
return `modpackqt · ${text}`;
|
|
84
|
-
};
|
|
54
|
+
node.brandStatus = function (text) { return text; };
|
|
85
55
|
|
|
86
56
|
node.opsToday = function () { return node._opsCount; };
|
|
87
57
|
|
|
@@ -323,8 +293,7 @@ module.exports = function (RED) {
|
|
|
323
293
|
node._slaveServer.on('socketError', (err) => node.warn(`[slave] socket error: ${err.message}`));
|
|
324
294
|
node._slaveServer.on('serverError', (err) => node.error(`[slave] server error: ${err.message}`));
|
|
325
295
|
node.log(
|
|
326
|
-
`[modpackqt] embedded Modbus slave listening on ${node.slaveHost}:${node.slavePort}
|
|
327
|
-
`powered by ModPackQT (${UPGRADE_URL})`
|
|
296
|
+
`[modpackqt] embedded Modbus slave listening on ${node.slaveHost}:${node.slavePort}`
|
|
328
297
|
);
|
|
329
298
|
} catch (err) {
|
|
330
299
|
node.error(`Failed to start embedded slave server: ${err.message}`);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('modpackqt-master-probe', {
|
|
3
|
+
category: 'ModPackQT',
|
|
4
|
+
color: '#7c3aed',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
targetHost: { value: '192.168.1.10', required: true },
|
|
8
|
+
targetPort: { value: 502, required: true, validate: RED.validators.number() },
|
|
9
|
+
unitId: { value: 1, required: true, validate: RED.validators.number() },
|
|
10
|
+
timeoutMs: { value: 3000, validate: RED.validators.number() }
|
|
11
|
+
},
|
|
12
|
+
inputs: 0,
|
|
13
|
+
outputs: 0,
|
|
14
|
+
icon: 'font-awesome/fa-rocket',
|
|
15
|
+
label: function () {
|
|
16
|
+
return this.name || `master probe ${this.targetHost}:${this.targetPort} #${this.unitId}`;
|
|
17
|
+
},
|
|
18
|
+
paletteLabel: 'modbus master probe',
|
|
19
|
+
oneditprepare: function () {
|
|
20
|
+
const node = this;
|
|
21
|
+
const buildLink = (info) => {
|
|
22
|
+
const host = (info && info.host) || '127.0.0.1';
|
|
23
|
+
const port = (info && info.port) || 8502;
|
|
24
|
+
const url = 'https://modpackqt.com/console'
|
|
25
|
+
+ '?gateway=local'
|
|
26
|
+
+ '&host=' + encodeURIComponent(host)
|
|
27
|
+
+ '&port=' + port
|
|
28
|
+
+ '&probe=' + encodeURIComponent(node.id)
|
|
29
|
+
+ '&kind=master';
|
|
30
|
+
$('#modpackqt-probe-link').attr('href', url);
|
|
31
|
+
$('#modpackqt-probe-runtime').text(host + ':' + port);
|
|
32
|
+
};
|
|
33
|
+
$.getJSON('modpackqt-probe/info').done(buildLink).fail(() => buildLink({}));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script type="text/html" data-template-name="modpackqt-master-probe">
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
41
|
+
<input type="text" id="node-input-name" placeholder="(optional, e.g. Inverter A)">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-input-targetHost"><i class="fa fa-plug"></i> Target Host</label>
|
|
45
|
+
<input type="text" id="node-input-targetHost" placeholder="192.168.1.10">
|
|
46
|
+
</div>
|
|
47
|
+
<div class="form-row">
|
|
48
|
+
<label for="node-input-targetPort"><i class="fa fa-hashtag"></i> Target Port</label>
|
|
49
|
+
<input type="number" id="node-input-targetPort" min="1" max="65535" placeholder="502">
|
|
50
|
+
</div>
|
|
51
|
+
<div class="form-row">
|
|
52
|
+
<label for="node-input-unitId"><i class="fa fa-id-card"></i> Unit ID</label>
|
|
53
|
+
<input type="number" id="node-input-unitId" min="1" max="247" placeholder="1">
|
|
54
|
+
</div>
|
|
55
|
+
<div class="form-row">
|
|
56
|
+
<label for="node-input-timeoutMs"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
|
|
57
|
+
<input type="number" id="node-input-timeoutMs" placeholder="3000">
|
|
58
|
+
</div>
|
|
59
|
+
<div class="form-tips">
|
|
60
|
+
<b>One probe = one device.</b> Drop additional master-probe nodes for additional devices —
|
|
61
|
+
the web console aggregates them all into a unified sidebar.
|
|
62
|
+
</div>
|
|
63
|
+
<div class="form-row" style="margin-top:18px;padding-top:14px;border-top:1px solid #e5e7eb;text-align:center;">
|
|
64
|
+
<a id="modpackqt-probe-link" href="https://modpackqt.com" target="_blank" rel="noopener noreferrer"
|
|
65
|
+
style="display:inline-block;padding:10px 24px;background:#7c3aed;color:#fff;
|
|
66
|
+
text-decoration:none;border-radius:6px;font-weight:600;font-size:14px;
|
|
67
|
+
box-shadow:0 1px 2px rgba(0,0,0,0.08);">
|
|
68
|
+
Open in ModPackQT Console <i class="fa fa-external-link" style="margin-left:6px;"></i>
|
|
69
|
+
</a>
|
|
70
|
+
<div style="margin-top:6px;font-size:11px;color:#6b7280;">
|
|
71
|
+
Live register inspector, scanner, decoder & AI helper · runtime <span id="modpackqt-probe-runtime">starting…</span>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<script type="text/html" data-help-name="modpackqt-master-probe">
|
|
77
|
+
<p>Live tester / commissioning probe for a single Modbus TCP device.
|
|
78
|
+
Drop one node per device, then click <b>Open in ModPackQT Console</b>
|
|
79
|
+
to launch the web tester live-attached to this connection.</p>
|
|
80
|
+
|
|
81
|
+
<h3>How it works</h3>
|
|
82
|
+
<p>The first probe deployed in this Node-RED instance starts a small local
|
|
83
|
+
HTTP server (default <code>127.0.0.1:8502</code>). The
|
|
84
|
+
<a href="https://modpackqt.com" target="_blank">modpackqt.com web console</a>
|
|
85
|
+
calls that local server to read/write registers, scan ranges, decode bytes,
|
|
86
|
+
and save profiles.</p>
|
|
87
|
+
|
|
88
|
+
<h3>Multiple devices</h3>
|
|
89
|
+
<p>Drop one master-probe per device. The web console auto-aggregates all
|
|
90
|
+
probe nodes from this Node-RED instance into a single sidebar — switch
|
|
91
|
+
between devices with one click.</p>
|
|
92
|
+
|
|
93
|
+
<h3>Network access</h3>
|
|
94
|
+
<p>The runtime binds to <code>127.0.0.1</code> by default — only browsers on
|
|
95
|
+
the same machine can reach it. To allow remote access from another machine's
|
|
96
|
+
browser, set environment variable <code>MODPACKQT_PROBE_HOST=0.0.0.0</code>
|
|
97
|
+
before starting Node-RED (and ensure your firewall allows port 8502).</p>
|
|
98
|
+
|
|
99
|
+
<h3>No inputs / outputs</h3>
|
|
100
|
+
<p>Probe nodes are commissioning tools — they don't participate in flows.
|
|
101
|
+
Use the regular <code>modpackqt-master-read</code> / <code>-write</code>
|
|
102
|
+
nodes to wire Modbus into your flow logic.</p>
|
|
103
|
+
</script>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModPackQT Master Probe — live commissioning probe for a single Modbus TCP device.
|
|
3
|
+
*
|
|
4
|
+
* Drop one node per device. The hidden runtime exposes it on the local HTTP
|
|
5
|
+
* API that the modpackqt.com web console drives.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = function (RED) {
|
|
8
|
+
const ModbusRTU = require('modbus-serial');
|
|
9
|
+
const { getRuntime } = require('./lib/probe-runtime');
|
|
10
|
+
|
|
11
|
+
function MasterProbeNode(config) {
|
|
12
|
+
RED.nodes.createNode(this, config);
|
|
13
|
+
const node = this;
|
|
14
|
+
|
|
15
|
+
node.name = config.name || '';
|
|
16
|
+
node.targetHost = config.targetHost || '192.168.1.10';
|
|
17
|
+
node.targetPort = parseInt(config.targetPort, 10) || 502;
|
|
18
|
+
node.unitId = parseInt(config.unitId, 10) || 1;
|
|
19
|
+
node.timeoutMs = parseInt(config.timeoutMs, 10) || 3000;
|
|
20
|
+
|
|
21
|
+
let client = null;
|
|
22
|
+
let connecting = null;
|
|
23
|
+
|
|
24
|
+
async function getClient() {
|
|
25
|
+
if (client && client.isOpen) return client;
|
|
26
|
+
if (connecting) return connecting;
|
|
27
|
+
connecting = (async () => {
|
|
28
|
+
const c = new ModbusRTU();
|
|
29
|
+
c.setTimeout(node.timeoutMs);
|
|
30
|
+
await c.connectTCP(node.targetHost, { port: node.targetPort });
|
|
31
|
+
c.setID(node.unitId);
|
|
32
|
+
client = c;
|
|
33
|
+
node.status({ fill: 'green', shape: 'dot', text: `connected ${node.targetHost}:${node.targetPort}` });
|
|
34
|
+
return c;
|
|
35
|
+
})().catch((err) => {
|
|
36
|
+
node.status({ fill: 'red', shape: 'ring', text: `connect failed: ${err.message}` });
|
|
37
|
+
throw err;
|
|
38
|
+
}).finally(() => { connecting = null; });
|
|
39
|
+
return connecting;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const probe = {
|
|
43
|
+
id: node.id,
|
|
44
|
+
kind: 'master',
|
|
45
|
+
describe(_detailed = false) {
|
|
46
|
+
return {
|
|
47
|
+
id: node.id,
|
|
48
|
+
kind: 'master',
|
|
49
|
+
name: node.name || `Master @ ${node.targetHost}:${node.targetPort} #${node.unitId}`,
|
|
50
|
+
target: {
|
|
51
|
+
mode: 'tcp',
|
|
52
|
+
host: node.targetHost,
|
|
53
|
+
port: node.targetPort,
|
|
54
|
+
unitId: node.unitId,
|
|
55
|
+
timeoutMs: node.timeoutMs
|
|
56
|
+
},
|
|
57
|
+
status: client && client.isOpen ? 'connected' : 'disconnected'
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async handleRead(body) {
|
|
61
|
+
const fc = parseInt(body.fc, 10);
|
|
62
|
+
const addr = parseInt(body.address, 10) || 0;
|
|
63
|
+
const qty = parseInt(body.quantity, 10) || 1;
|
|
64
|
+
const c = await getClient();
|
|
65
|
+
c.setID(node.unitId);
|
|
66
|
+
const t0 = Date.now();
|
|
67
|
+
let res;
|
|
68
|
+
switch (fc) {
|
|
69
|
+
case 1: res = await c.readCoils(addr, qty); break;
|
|
70
|
+
case 2: res = await c.readDiscreteInputs(addr, qty); break;
|
|
71
|
+
case 3: res = await c.readHoldingRegisters(addr, qty); break;
|
|
72
|
+
case 4: res = await c.readInputRegisters(addr, qty); break;
|
|
73
|
+
default: throw new Error(`unsupported read FC: ${fc}`);
|
|
74
|
+
}
|
|
75
|
+
return { values: res.data, durationMs: Date.now() - t0 };
|
|
76
|
+
},
|
|
77
|
+
async handleWrite(body) {
|
|
78
|
+
const fc = parseInt(body.fc, 10);
|
|
79
|
+
const addr = parseInt(body.address, 10) || 0;
|
|
80
|
+
const values = body.values;
|
|
81
|
+
if (!Array.isArray(values) && fc !== 5 && fc !== 6) {
|
|
82
|
+
throw new Error('values must be an array for FC15/FC16');
|
|
83
|
+
}
|
|
84
|
+
const c = await getClient();
|
|
85
|
+
c.setID(node.unitId);
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
switch (fc) {
|
|
88
|
+
case 5: await c.writeCoil(addr, !!(Array.isArray(values) ? values[0] : values)); break;
|
|
89
|
+
case 6: await c.writeRegister(addr, Array.isArray(values) ? values[0] : values); break;
|
|
90
|
+
case 15: await c.writeCoils(addr, values.map(Boolean)); break;
|
|
91
|
+
case 16: await c.writeRegisters(addr, values); break;
|
|
92
|
+
default: throw new Error(`unsupported write FC: ${fc}`);
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, durationMs: Date.now() - t0 };
|
|
95
|
+
},
|
|
96
|
+
async handleStoreGet() { throw new Error('master probe has no register store — use /read'); },
|
|
97
|
+
async handleStorePut() { throw new Error('master probe has no register store — use /write'); }
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const runtime = getRuntime();
|
|
101
|
+
runtime.ensureAdminRoutes(RED);
|
|
102
|
+
const info = runtime.register(probe);
|
|
103
|
+
node.status({ fill: 'blue', shape: 'ring', text: `probe ready · ${info.host}:${info.port || '?'}` });
|
|
104
|
+
|
|
105
|
+
node.on('close', function (done) {
|
|
106
|
+
runtime.unregister(node.id);
|
|
107
|
+
if (client && client.isOpen) {
|
|
108
|
+
try { client.close(() => done()); } catch (_) { done(); }
|
|
109
|
+
} else {
|
|
110
|
+
done();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
RED.nodes.registerType('modpackqt-master-probe', MasterProbeNode);
|
|
116
|
+
};
|
|
@@ -90,6 +90,4 @@
|
|
|
90
90
|
<dt>topic <span class="property-type">string</span></dt>
|
|
91
91
|
<dd><code>modpackqt/read/{host}:{port}/fc{N}/{address}</code></dd>
|
|
92
92
|
</dl>
|
|
93
|
-
<h3>Free tier</h3>
|
|
94
|
-
<p>1,000 ops/day per Node-RED instance. Add an API key in the runtime config for unlimited use.</p>
|
|
95
93
|
</script>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('modpackqt-slave-probe', {
|
|
3
|
+
category: 'ModPackQT',
|
|
4
|
+
color: '#7c3aed',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
bindHost: { value: '0.0.0.0' },
|
|
8
|
+
bindPort: { value: 1502, required: true, validate: RED.validators.number() },
|
|
9
|
+
unitId: { value: 1, required: true, validate: RED.validators.number() }
|
|
10
|
+
},
|
|
11
|
+
inputs: 0,
|
|
12
|
+
outputs: 0,
|
|
13
|
+
icon: 'font-awesome/fa-server',
|
|
14
|
+
label: function () {
|
|
15
|
+
return this.name || `slave probe :${this.bindPort} #${this.unitId}`;
|
|
16
|
+
},
|
|
17
|
+
paletteLabel: 'modbus slave probe',
|
|
18
|
+
oneditprepare: function () {
|
|
19
|
+
const node = this;
|
|
20
|
+
const buildLink = (info) => {
|
|
21
|
+
const host = (info && info.host) || '127.0.0.1';
|
|
22
|
+
const port = (info && info.port) || 8502;
|
|
23
|
+
const url = 'https://modpackqt.com/console'
|
|
24
|
+
+ '?gateway=local'
|
|
25
|
+
+ '&host=' + encodeURIComponent(host)
|
|
26
|
+
+ '&port=' + port
|
|
27
|
+
+ '&probe=' + encodeURIComponent(node.id)
|
|
28
|
+
+ '&kind=slave';
|
|
29
|
+
$('#modpackqt-slave-probe-link').attr('href', url);
|
|
30
|
+
$('#modpackqt-slave-probe-runtime').text(host + ':' + port);
|
|
31
|
+
};
|
|
32
|
+
$.getJSON('modpackqt-probe/info').done(buildLink).fail(() => buildLink({}));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<script type="text/html" data-template-name="modpackqt-slave-probe">
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
40
|
+
<input type="text" id="node-input-name" placeholder="(optional, e.g. Fake Inverter)">
|
|
41
|
+
</div>
|
|
42
|
+
<div class="form-row">
|
|
43
|
+
<label for="node-input-bindHost"><i class="fa fa-globe"></i> Bind Host</label>
|
|
44
|
+
<input type="text" id="node-input-bindHost" placeholder="0.0.0.0 (all interfaces)">
|
|
45
|
+
</div>
|
|
46
|
+
<div class="form-row">
|
|
47
|
+
<label for="node-input-bindPort"><i class="fa fa-hashtag"></i> Listen Port</label>
|
|
48
|
+
<input type="number" id="node-input-bindPort" min="1" max="65535" placeholder="1502">
|
|
49
|
+
</div>
|
|
50
|
+
<div class="form-row">
|
|
51
|
+
<label for="node-input-unitId"><i class="fa fa-id-card"></i> Unit ID</label>
|
|
52
|
+
<input type="number" id="node-input-unitId" min="1" max="247" placeholder="1">
|
|
53
|
+
</div>
|
|
54
|
+
<div class="form-tips">
|
|
55
|
+
<b>One probe = one fake slave.</b> For multiple slaves, drop one node per
|
|
56
|
+
port. The web console aggregates all slave probes into a unified register
|
|
57
|
+
editor — switch between them with one click.
|
|
58
|
+
</div>
|
|
59
|
+
<div class="form-row" style="margin-top:18px;padding-top:14px;border-top:1px solid #e5e7eb;text-align:center;">
|
|
60
|
+
<a id="modpackqt-slave-probe-link" href="https://modpackqt.com" target="_blank" rel="noopener noreferrer"
|
|
61
|
+
style="display:inline-block;padding:10px 24px;background:#7c3aed;color:#fff;
|
|
62
|
+
text-decoration:none;border-radius:6px;font-weight:600;font-size:14px;
|
|
63
|
+
box-shadow:0 1px 2px rgba(0,0,0,0.08);">
|
|
64
|
+
Open in ModPackQT Console <i class="fa fa-external-link" style="margin-left:6px;"></i>
|
|
65
|
+
</a>
|
|
66
|
+
<div style="margin-top:6px;font-size:11px;color:#6b7280;">
|
|
67
|
+
Live register editor, traffic monitor & AI helper · runtime <span id="modpackqt-slave-probe-runtime">starting…</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<script type="text/html" data-help-name="modpackqt-slave-probe">
|
|
73
|
+
<p>Live simulator probe for a single Modbus TCP slave. Drop one node per
|
|
74
|
+
fake device, configure listen port + unit ID, then click
|
|
75
|
+
<b>Open in ModPackQT Console</b> to launch the web register editor.</p>
|
|
76
|
+
|
|
77
|
+
<h3>How it works</h3>
|
|
78
|
+
<p>Each slave-probe owns a 65 536-register store per type (coils, discrete,
|
|
79
|
+
holding, input) and listens on its configured port. External Modbus
|
|
80
|
+
masters can connect and read/write — the modpackqt.com console gives
|
|
81
|
+
you a live editor for the register values.</p>
|
|
82
|
+
|
|
83
|
+
<h3>Multiple slaves</h3>
|
|
84
|
+
<p>Drop one slave-probe per port/unit combination. They appear together
|
|
85
|
+
in the web console sidebar — switch between them with one click,
|
|
86
|
+
compare register sets side-by-side.</p>
|
|
87
|
+
|
|
88
|
+
<h3>Note on ports</h3>
|
|
89
|
+
<p>Use ports above 1024 (e.g. 1502, 1503, 1504) to avoid needing root
|
|
90
|
+
privileges. Each probe must use a unique port.</p>
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModPackQT Slave Probe — live simulator probe for a single Modbus TCP slave.
|
|
3
|
+
*
|
|
4
|
+
* Drop one node per fake device. Each probe owns its own register store and
|
|
5
|
+
* binds its own port. The hidden runtime exposes it on the local HTTP API
|
|
6
|
+
* that the modpackqt.com web console drives (register editor + live traffic).
|
|
7
|
+
*/
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
const ModbusRTU = require('modbus-serial');
|
|
10
|
+
const { getRuntime } = require('./lib/probe-runtime');
|
|
11
|
+
|
|
12
|
+
function SlaveProbeNode(config) {
|
|
13
|
+
RED.nodes.createNode(this, config);
|
|
14
|
+
const node = this;
|
|
15
|
+
|
|
16
|
+
node.name = config.name || '';
|
|
17
|
+
node.bindHost = config.bindHost || '0.0.0.0';
|
|
18
|
+
node.bindPort = parseInt(config.bindPort, 10) || 1502;
|
|
19
|
+
node.unitId = parseInt(config.unitId, 10) || 1;
|
|
20
|
+
|
|
21
|
+
const store = {
|
|
22
|
+
coils: new Array(65536).fill(false),
|
|
23
|
+
discrete: new Array(65536).fill(false),
|
|
24
|
+
holding: new Array(65536).fill(0),
|
|
25
|
+
input: new Array(65536).fill(0)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let server = null;
|
|
29
|
+
try {
|
|
30
|
+
const vector = {
|
|
31
|
+
getCoil: (addr, _u, cb) => cb(null, store.coils[addr]),
|
|
32
|
+
getDiscreteInput: (addr, _u, cb) => cb(null, store.discrete[addr]),
|
|
33
|
+
getHoldingRegister: (addr, _u, cb) => cb(null, store.holding[addr]),
|
|
34
|
+
getInputRegister: (addr, _u, cb) => cb(null, store.input[addr]),
|
|
35
|
+
setCoil: (addr, val, _u, cb) => { store.coils[addr] = !!val; cb(null); },
|
|
36
|
+
setRegister: (addr, val, _u, cb) => { store.holding[addr] = val & 0xFFFF; cb(null); }
|
|
37
|
+
};
|
|
38
|
+
server = new ModbusRTU.ServerTCP(vector, {
|
|
39
|
+
host: node.bindHost,
|
|
40
|
+
port: node.bindPort,
|
|
41
|
+
debug: false,
|
|
42
|
+
unitID: node.unitId
|
|
43
|
+
});
|
|
44
|
+
server.on('socketError', (err) => node.warn(`[slave-probe] socket: ${err.message}`));
|
|
45
|
+
server.on('serverError', (err) => node.error(`[slave-probe] server: ${err.message}`));
|
|
46
|
+
node.log(`[modpackqt] slave probe listening on ${node.bindHost}:${node.bindPort} #${node.unitId}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
node.error(`Failed to start slave probe: ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const probe = {
|
|
52
|
+
id: node.id,
|
|
53
|
+
kind: 'slave',
|
|
54
|
+
describe(_detailed = false) {
|
|
55
|
+
return {
|
|
56
|
+
id: node.id,
|
|
57
|
+
kind: 'slave',
|
|
58
|
+
name: node.name || `Slave :${node.bindPort} #${node.unitId}`,
|
|
59
|
+
target: {
|
|
60
|
+
mode: 'tcp',
|
|
61
|
+
host: node.bindHost,
|
|
62
|
+
port: node.bindPort,
|
|
63
|
+
unitId: node.unitId
|
|
64
|
+
},
|
|
65
|
+
status: server ? 'listening' : 'error'
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
async handleRead() { throw new Error('slave probe — use GET /store to inspect registers'); },
|
|
69
|
+
async handleWrite() { throw new Error('slave probe — use PUT /store to set registers'); },
|
|
70
|
+
async handleStoreGet(searchParams) {
|
|
71
|
+
const type = searchParams.get('type') || 'holding';
|
|
72
|
+
const addr = parseInt(searchParams.get('address') || '0', 10);
|
|
73
|
+
const qty = parseInt(searchParams.get('quantity') || '10', 10);
|
|
74
|
+
if (!store[type]) throw new Error(`unknown register type: ${type}`);
|
|
75
|
+
return { type, address: addr, values: store[type].slice(addr, addr + qty) };
|
|
76
|
+
},
|
|
77
|
+
async handleStorePut(body) {
|
|
78
|
+
const type = body.type || 'holding';
|
|
79
|
+
const addr = parseInt(body.address, 10) || 0;
|
|
80
|
+
const values = body.values || [];
|
|
81
|
+
if (!store[type]) throw new Error(`unknown register type: ${type}`);
|
|
82
|
+
if (!Array.isArray(values)) throw new Error('values must be an array');
|
|
83
|
+
const isBool = (type === 'coils' || type === 'discrete');
|
|
84
|
+
const stored = values.map((v) => isBool ? Boolean(v) : (parseInt(v, 10) & 0xFFFF));
|
|
85
|
+
stored.forEach((v, i) => { store[type][addr + i] = v; });
|
|
86
|
+
return { ok: true, type, address: addr, written: stored.length };
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const runtime = getRuntime();
|
|
91
|
+
runtime.ensureAdminRoutes(RED);
|
|
92
|
+
runtime.register(probe);
|
|
93
|
+
|
|
94
|
+
if (server) {
|
|
95
|
+
node.status({ fill: 'green', shape: 'dot', text: `slave :${node.bindPort} #${node.unitId}` });
|
|
96
|
+
} else {
|
|
97
|
+
node.status({ fill: 'red', shape: 'ring', text: 'failed to start' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
node.on('close', function (done) {
|
|
101
|
+
runtime.unregister(node.id);
|
|
102
|
+
if (server) {
|
|
103
|
+
try { server.close(() => done()); } catch (_) { done(); }
|
|
104
|
+
} else {
|
|
105
|
+
done();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
RED.nodes.registerType('modpackqt-slave-probe', SlaveProbeNode);
|
|
111
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-modbus-modpackqt",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Node-RED
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "Modbus commissioning, testing & analysis tools for Node-RED. Embedded Modbus TCP/RTU master + slave server, FC1/FC2/FC3/FC4 reads, FC5/FC6/FC15/FC16 writes, built-in slave register store, and a passive traffic monitor for debugging. 100% free, MIT, no usage limits. By ModPackQT — open the matching web console at modpackqt.com for register decoding, simulation and AI assistance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
7
7
|
"modbus",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"modbus-master",
|
|
12
12
|
"modbus-slave",
|
|
13
13
|
"modbus-server",
|
|
14
|
+
"modbus-tester",
|
|
15
|
+
"modbus-commissioning",
|
|
16
|
+
"modbus-analyzer",
|
|
14
17
|
"industrial",
|
|
15
18
|
"automation",
|
|
16
19
|
"holding-register",
|
|
@@ -46,7 +49,9 @@
|
|
|
46
49
|
"modpackqt-master-write": "nodes/modpackqt-master-write.js",
|
|
47
50
|
"modpackqt-slave-read": "nodes/modpackqt-slave-read.js",
|
|
48
51
|
"modpackqt-slave-write": "nodes/modpackqt-slave-write.js",
|
|
49
|
-
"modpackqt-traffic": "nodes/modpackqt-traffic.js"
|
|
52
|
+
"modpackqt-traffic": "nodes/modpackqt-traffic.js",
|
|
53
|
+
"modpackqt-master-probe": "nodes/modpackqt-master-probe.js",
|
|
54
|
+
"modpackqt-slave-probe": "nodes/modpackqt-slave-probe.js"
|
|
50
55
|
}
|
|
51
56
|
},
|
|
52
57
|
"dependencies": {
|