modbus-webserial 0.10.1 → 0.11.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/README.md +97 -16
- package/dist/index.d.ts +30 -4
- package/dist/index.js +326 -60
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# modbus-webserial
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-dependency library for communicating with a Modbus-RTU serial device from the browser via WebSerial.
|
|
4
4
|
|
|
5
|
+
[](https://github.com/louisfoster/awesome-web-serial#code-utilities)
|
|
5
6
|

|
|
6
7
|

|
|
7
8
|

|
|
@@ -10,14 +11,13 @@ Tiny zero-dependency library for communicating with a Modbus-RTU serial device f
|
|
|
10
11
|

|
|
11
12
|

|
|
12
13
|
|
|
13
|
-
## Install
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install modbus-webserial
|
|
17
|
-
```
|
|
18
14
|
|
|
19
15
|
## Usage
|
|
20
|
-
**
|
|
16
|
+
**Try the web UI at**
|
|
17
|
+
**[modbuswebui.dev](https://modbuswebui.dev/)**
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
**Establish connection and read/write in browser**
|
|
21
21
|
```javascript
|
|
22
22
|
import { ModbusRTU } from 'modbus-webserial';
|
|
23
23
|
|
|
@@ -25,13 +25,25 @@ import { ModbusRTU } from 'modbus-webserial';
|
|
|
25
25
|
const client = await ModbusRTU.openWebSerial({ baudRate: 9600 });
|
|
26
26
|
client.setID(1);
|
|
27
27
|
|
|
28
|
-
// Read holding registers
|
|
28
|
+
// Read two holding registers from 0x0000 (0x0000 and 0x0001)
|
|
29
29
|
const { data } = await client.readHoldingRegisters(0, 2);
|
|
30
30
|
console.log('HR0=', data[0], 'HR1=', data[1]);
|
|
31
31
|
|
|
32
|
-
// Write values to holding registers 0x00 and 0x01
|
|
32
|
+
// Write values to holding registers 0x00 and 0x01 (i.e. two registers from 0x0000)
|
|
33
33
|
await client.writeRegisters(0, [0x0A, 0x0B]);
|
|
34
34
|
```
|
|
35
|
+
**You can also manually supply the port**
|
|
36
|
+
```javascript
|
|
37
|
+
const [port] = await navigator.serial.getPorts()
|
|
38
|
+
const client = await ModbusRTU.openWebSerial({ baudRate: 9600, port })
|
|
39
|
+
|
|
40
|
+
// or on connect
|
|
41
|
+
navigator.serial.addEventListener("connect", ({target: port}) => {
|
|
42
|
+
const client = await ModbusRTU.openWebSerial({ baudRate: 9600, port })
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
35
47
|
**Can also be used *without* WebSerial for building modbus frames in any environment**
|
|
36
48
|
```javascript
|
|
37
49
|
import {
|
|
@@ -42,15 +54,18 @@ import {
|
|
|
42
54
|
// Build a “Read Holding Registers” frame (ID=1, addr=0, qty=2)
|
|
43
55
|
const rawRead = buildReadHoldingRegisters(1, 0x00, 2);
|
|
44
56
|
console.log(rawRead);
|
|
45
|
-
//
|
|
57
|
+
// Uint8Array [0x01, 0x03, 0x00, 0x00, 0x00, 0x02, CRC_LO, CRC_HI]
|
|
46
58
|
|
|
47
59
|
// Build a “Write Multiple Registers” frame (ID=1, addr=0, values=[10,11])
|
|
48
60
|
const rawWrite = buildWriteRegisters(1, 0x00, [0x0A, 0x0B]);
|
|
49
61
|
console.log(rawWrite);
|
|
50
|
-
//
|
|
62
|
+
// Uint8Array [0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00,0x0A, 0x00,0x0B, CRC_LO, CRC_HI]
|
|
51
63
|
```
|
|
52
64
|
> [!TIP]
|
|
53
65
|
> Check `src/index.ts` (or `dist/index.js`) for all exports
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
54
69
|
## Supported Functions
|
|
55
70
|
|
|
56
71
|
### Modbus Data Functions
|
|
@@ -86,6 +101,11 @@ Utility and configuration methods exposed on `ModbusRTU`:
|
|
|
86
101
|
| `getID()` | Get the current slave ID |
|
|
87
102
|
| `setTimeout(ms)` | Set transaction timeout (ms) |
|
|
88
103
|
| `getTimeout()` | Get current timeout (ms) |
|
|
104
|
+
| `getPort()` | Get 'SerialPort' instance[^1] |
|
|
105
|
+
|
|
106
|
+
[^1]: The returned `SerialPort` instance from `getPort` can be used to access properties such as `usbVendorId` and `usbProductId` for retrieving information about the connected USB device.
|
|
107
|
+
|
|
108
|
+
---
|
|
89
109
|
|
|
90
110
|
## Examples
|
|
91
111
|
|
|
@@ -96,7 +116,72 @@ The following demos are fully self‑contained HTML files, served via GitHub Pag
|
|
|
96
116
|
* [64‑Register Smoke Test](https://anttikotajarvi.github.io/modbus-webserial/examples/smoke-test/)
|
|
97
117
|
Automated loop testing read/write of 64 registers, coils, and discrete inputs with live counters and error logging.
|
|
98
118
|
|
|
119
|
+
* [Modbus Web-UI](https://modbuswebui.dev)
|
|
120
|
+
MB Master web app with UX fetures etc.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Behavior
|
|
125
|
+
### CRC policy (strict vs resync)
|
|
126
|
+
|
|
127
|
+
By default the transport uses **strict** CRC checking: if a received Modbus RTU frame fails CRC validation, the request fails with `CrcError`.
|
|
128
|
+
|
|
129
|
+
You can switch to a more tolerant mode that tries to recover from occasional line noise:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const client = await ModbusRTU.openWebSerial({
|
|
133
|
+
baudRate: 9600,
|
|
134
|
+
crcPolicy: { mode: "resync", maxResyncDrops: 32 },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Concurrency
|
|
139
|
+
The WebSerial transport is **single-flight**: it can only have one in-progress Modbus RTU transaction at a time.
|
|
140
|
+
|
|
141
|
+
This is intentional. Modbus RTU does not include a transaction identifier, so overlapping requests on the same serial link makes it ambiguous which response belongs to which request.
|
|
142
|
+
|
|
143
|
+
There is **no internal request queue**. To avoid abstracting away the serial “one request at a time” nature of Modbus RTU, starting a new transaction while another is in progress will throw an error immediately.
|
|
144
|
+
|
|
145
|
+
#### Wrong (concurrent calls on the same client/transport):
|
|
146
|
+
```js
|
|
147
|
+
// Two requests started without awaiting the first one.
|
|
148
|
+
// This will throw (single-flight).
|
|
149
|
+
const p1 = client.readHoldingRegisters(0x0000, 2);
|
|
150
|
+
const p2 = client.readHoldingRegisters(0x0010, 2);
|
|
151
|
+
// -> Uncaught Error: Concurrent transact() calls are not supported
|
|
152
|
+
|
|
153
|
+
await Promise.all([p1, p2]);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
#### Right (serialize with `await`):
|
|
157
|
+
```js
|
|
158
|
+
await client.readHoldingRegisters(0x0000, 2);
|
|
159
|
+
await client.readHoldingRegisters(0x0010, 2);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Post-timeout recovery
|
|
163
|
+
|
|
164
|
+
When a request times out, a Modbus RTU slave may still send a **late reply** afterwards. If you immediately send a new request (that is similar enough to the old one), that late reply can be mistaken as the response for the new request.
|
|
165
|
+
|
|
166
|
+
To reduce that risk (along with strong request/response matching), the transport supports a post-timeout “quarantine” window.
|
|
167
|
+
|
|
168
|
+
* If `postTimeoutWaitPeriod` is `0` (default), the next request is sent immediately after a timeout.
|
|
169
|
+
* If `postTimeoutWaitPeriod` is `> 0`, the next `transact()` call will **delay sending** the request for the specified period, while **discarding** any bytes received during that window.
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
const client = await ModbusRTU.openWebSerial({
|
|
173
|
+
// ...
|
|
174
|
+
postTimeoutWaitPeriod: 20, // ms
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Notes:
|
|
179
|
+
* This affects **only the request after a timeout**.
|
|
180
|
+
* When you use the client sequentially (with `await`), this shows up as a small delay before the next request. It does **not** change how timeouts are thrown or how you catch them.
|
|
181
|
+
|
|
182
|
+
|
|
99
183
|
## Current state
|
|
184
|
+
* **v0.11**: Transport rework to handle edge cases explicitly
|
|
100
185
|
* **v0.10**: Full modbus data-access coverage
|
|
101
186
|
* **v0.9**: Full passing tests, smoke test passed, complete README, build scripts in place
|
|
102
187
|
* **Beta**: Full Modbus RTU function‑code coverage
|
|
@@ -104,8 +189,4 @@ The following demos are fully self‑contained HTML files, served via GitHub Pag
|
|
|
104
189
|
|
|
105
190
|
## Roadmap
|
|
106
191
|
|
|
107
|
-
* **v1.0.0**:
|
|
108
|
-
|
|
109
|
-
---
|
|
110
|
-
|
|
111
|
-
© 2025 Antti Kotajärvi
|
|
192
|
+
* **v1.0.0**: Create and document more tests for different boards using different browsers.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
interface WebSerialOptions {
|
|
1
|
+
interface WebSerialOptions extends WebSerialConfig {
|
|
2
|
+
requestFilters?: SerialPortFilter[];
|
|
3
|
+
port?: SerialPort;
|
|
4
|
+
}
|
|
5
|
+
interface WebSerialConfig {
|
|
2
6
|
baudRate?: number;
|
|
3
7
|
dataBits?: 7 | 8;
|
|
4
8
|
stopBits?: 1 | 2;
|
|
5
|
-
parity?:
|
|
6
|
-
requestFilters?: SerialPortFilter[];
|
|
9
|
+
parity?: "none" | "even" | "odd";
|
|
7
10
|
timeout?: number;
|
|
11
|
+
crcPolicy?: Required<CrcPolicy>;
|
|
12
|
+
postTimeoutWaitPeriod?: number;
|
|
13
|
+
interRequestDelay?: number;
|
|
8
14
|
}
|
|
15
|
+
type CrcPolicy = {
|
|
16
|
+
mode: "strict";
|
|
17
|
+
} | {
|
|
18
|
+
mode: "resync";
|
|
19
|
+
maxResyncDrops?: number;
|
|
20
|
+
};
|
|
9
21
|
|
|
10
22
|
/** Result payloads returned by high-level helpers */
|
|
11
23
|
interface ReadCoilResult {
|
|
@@ -194,12 +206,26 @@ declare function crc16(buf: Uint8Array): number;
|
|
|
194
206
|
declare class CrcError extends Error {
|
|
195
207
|
constructor();
|
|
196
208
|
}
|
|
209
|
+
declare class ResyncError extends Error {
|
|
210
|
+
readonly drops: number;
|
|
211
|
+
readonly maxDrops: number;
|
|
212
|
+
constructor(drops: number, maxDrops: number);
|
|
213
|
+
}
|
|
197
214
|
declare class TimeoutError extends Error {
|
|
198
215
|
constructor();
|
|
199
216
|
}
|
|
217
|
+
declare class StreamClosedError extends Error {
|
|
218
|
+
constructor();
|
|
219
|
+
}
|
|
200
220
|
declare class ExceptionError extends Error {
|
|
201
221
|
code: number;
|
|
202
222
|
constructor(code: number);
|
|
203
223
|
}
|
|
204
224
|
|
|
205
|
-
|
|
225
|
+
declare const BUILD_INFO: {
|
|
226
|
+
readonly version: string;
|
|
227
|
+
readonly commit: string;
|
|
228
|
+
readonly buildTime: string;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export { BUILD_INFO, CrcError, ExceptionError, type MaskWriteResult, ModbusRTU, type ReadFifoResult, type ReadRegisterResult, ResyncError, StreamClosedError, TimeoutError, type WebSerialOptions, type WriteFileResult, type WriteRegisterResult, buildMaskWriteRegister, buildReadCoils, buildReadDiscreteInputs, buildReadFifoQueue, buildReadFileRecord, buildReadHolding, buildReadInputRegisters, buildReadWriteMultiple, buildWriteFileRecord, buildWriteMultiple, buildWriteMultipleCoils, buildWriteSingle, buildWriteSingleCoil, crc16, parseMaskWriteRegister, parseReadCoils, parseReadDiscreteInputs, parseReadFifoQueue, parseReadFileRecord, parseReadHolding, parseReadInputRegisters, parseReadWriteMultiple, parseWriteSingle, parseWriteSingleCoil };
|
package/dist/index.js
CHANGED
|
@@ -15,11 +15,24 @@ var CrcError = class extends Error {
|
|
|
15
15
|
super("CRC check failed");
|
|
16
16
|
}
|
|
17
17
|
};
|
|
18
|
+
var ResyncError = class extends Error {
|
|
19
|
+
constructor(drops, maxDrops) {
|
|
20
|
+
super(`Resync failed (dropped ${drops}/${maxDrops} bytes)`);
|
|
21
|
+
this.name = "ResyncError";
|
|
22
|
+
this.drops = drops;
|
|
23
|
+
this.maxDrops = maxDrops;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
18
26
|
var TimeoutError = class extends Error {
|
|
19
27
|
constructor() {
|
|
20
28
|
super("Modbus response timed out");
|
|
21
29
|
}
|
|
22
30
|
};
|
|
31
|
+
var StreamClosedError = class extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("Modbus stream closed");
|
|
34
|
+
}
|
|
35
|
+
};
|
|
23
36
|
var ExceptionError = class extends Error {
|
|
24
37
|
constructor(code) {
|
|
25
38
|
var _a;
|
|
@@ -40,19 +53,59 @@ function crc16(buf) {
|
|
|
40
53
|
return crc;
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
// src/core/types.ts
|
|
57
|
+
var FC_READ_HOLDING_REGISTERS = 3;
|
|
58
|
+
var FC_WRITE_SINGLE_HOLDING_REGISTER = 6;
|
|
59
|
+
var FC_WRITE_MULTIPLE_HOLDING_REGISTERS = 16;
|
|
60
|
+
var FC_READ_COILS = 1;
|
|
61
|
+
var FC_WRITE_SINGLE_COIL = 5;
|
|
62
|
+
var FC_WRITE_MULTIPLE_COILS = 15;
|
|
63
|
+
var FC_READ_INPUT_REGISTERS = 4;
|
|
64
|
+
var FC_READ_DISCRETE_INPUTS = 2;
|
|
65
|
+
var FC_READ_FILE_RECORD = 20;
|
|
66
|
+
var FC_WRITE_FILE_RECORD = 21;
|
|
67
|
+
var FC_MASK_WRITE_REGISTER = 22;
|
|
68
|
+
var FC_READ_WRITE_MULTIPLE_REGISTERS = 23;
|
|
69
|
+
var FC_READ_FIFO_QUEUE = 24;
|
|
70
|
+
|
|
43
71
|
// src/transport/webserial.ts
|
|
44
|
-
var
|
|
72
|
+
var DEFAULT_CRC_POLICY = { mode: "strict" };
|
|
73
|
+
var DEFAULT_MAX_RESYNC_DROPS = 32;
|
|
74
|
+
var DEFAULTS = {
|
|
75
|
+
baudRate: 9600,
|
|
76
|
+
dataBits: 8,
|
|
77
|
+
stopBits: 1,
|
|
78
|
+
parity: "none",
|
|
79
|
+
timeout: 500,
|
|
80
|
+
crcPolicy: DEFAULT_CRC_POLICY,
|
|
81
|
+
postTimeoutWaitPeriod: 0,
|
|
82
|
+
interRequestDelay: 0
|
|
83
|
+
};
|
|
84
|
+
var _WebSerialTransport = class _WebSerialTransport {
|
|
45
85
|
constructor() {
|
|
46
|
-
this.timeout = 500;
|
|
47
86
|
this.rxBuf = new Uint8Array(0);
|
|
87
|
+
// rolling buffer across calls
|
|
88
|
+
this.dirtyUntil = 0;
|
|
89
|
+
this.inFlight = false;
|
|
90
|
+
this.lastTransactionEnd = null;
|
|
91
|
+
this.cfg = {
|
|
92
|
+
baudRate: DEFAULTS.baudRate,
|
|
93
|
+
dataBits: DEFAULTS.dataBits,
|
|
94
|
+
stopBits: DEFAULTS.stopBits,
|
|
95
|
+
parity: DEFAULTS.parity,
|
|
96
|
+
timeout: DEFAULTS.timeout,
|
|
97
|
+
postTimeoutWaitPeriod: DEFAULTS.postTimeoutWaitPeriod,
|
|
98
|
+
interRequestDelay: DEFAULTS.interRequestDelay
|
|
99
|
+
};
|
|
100
|
+
this.MAX_RESYNC_DROPS = DEFAULT_MAX_RESYNC_DROPS;
|
|
101
|
+
this.CRC_STRICT = DEFAULT_CRC_POLICY.mode === "strict";
|
|
48
102
|
}
|
|
49
|
-
// rolling buffer across calls
|
|
50
103
|
// timeout gelpers
|
|
51
104
|
setTimeout(ms) {
|
|
52
|
-
this.timeout = ms;
|
|
105
|
+
this.cfg.timeout = ms;
|
|
53
106
|
}
|
|
54
107
|
getTimeout() {
|
|
55
|
-
return this.timeout;
|
|
108
|
+
return this.cfg.timeout;
|
|
56
109
|
}
|
|
57
110
|
getPort() {
|
|
58
111
|
return this.port;
|
|
@@ -66,72 +119,202 @@ var WebSerialTransport = class _WebSerialTransport {
|
|
|
66
119
|
return t;
|
|
67
120
|
}
|
|
68
121
|
async init(opts) {
|
|
69
|
-
var _a, _b, _c
|
|
70
|
-
this.
|
|
71
|
-
|
|
122
|
+
var _a, _b, _c;
|
|
123
|
+
Object.entries(this.cfg).forEach(([key, defaultValue]) => {
|
|
124
|
+
if (typeof opts[key] !== "undefined") {
|
|
125
|
+
this.cfg[key] = opts[key];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (opts.crcPolicy) {
|
|
129
|
+
this.CRC_STRICT = opts.crcPolicy.mode === "strict";
|
|
130
|
+
this.MAX_RESYNC_DROPS = opts.crcPolicy.mode === "resync" ? (_a = opts.crcPolicy.maxResyncDrops) != null ? _a : DEFAULT_MAX_RESYNC_DROPS : 0;
|
|
131
|
+
}
|
|
132
|
+
this.port = (_c = opts.port) != null ? _c : await navigator.serial.requestPort({
|
|
133
|
+
filters: (_b = opts.requestFilters) != null ? _b : []
|
|
134
|
+
});
|
|
72
135
|
await this.port.open({
|
|
73
|
-
baudRate:
|
|
74
|
-
dataBits:
|
|
75
|
-
stopBits:
|
|
76
|
-
parity:
|
|
136
|
+
baudRate: this.cfg.baudRate,
|
|
137
|
+
dataBits: this.cfg.dataBits,
|
|
138
|
+
stopBits: this.cfg.stopBits,
|
|
139
|
+
parity: this.cfg.parity
|
|
77
140
|
});
|
|
78
141
|
this.reader = this.port.readable.getReader();
|
|
79
142
|
this.writer = this.port.writable.getWriter();
|
|
143
|
+
if (typeof performance !== "undefined") {
|
|
144
|
+
this.now = () => performance.now();
|
|
145
|
+
}
|
|
80
146
|
}
|
|
81
147
|
// ----------------------------------------------------------------
|
|
82
148
|
// transact(): Send `req` and await a response whose function-code matches the request
|
|
83
149
|
// ---------------------------------------------------------------- */
|
|
84
150
|
async transact(req) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
this.rxBuf = concat(this.rxBuf, value);
|
|
94
|
-
}
|
|
95
|
-
const need = this.frameLengthIfComplete(this.rxBuf);
|
|
96
|
-
if (need === 0) {
|
|
97
|
-
if (Date.now() > deadline) throw new TimeoutError();
|
|
98
|
-
const { value } = await this.reader.read();
|
|
99
|
-
if (!value) throw new TimeoutError();
|
|
100
|
-
this.rxBuf = concat(this.rxBuf, value);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
const frame = this.rxBuf.slice(0, need);
|
|
104
|
-
this.rxBuf = this.rxBuf.slice(need);
|
|
105
|
-
const crc = crc16(frame.subarray(0, frame.length - 2));
|
|
106
|
-
const crcOk = (crc & 255) === frame[frame.length - 2] && crc >> 8 === frame[frame.length - 1];
|
|
107
|
-
if (!crcOk) {
|
|
108
|
-
this.rxBuf = this.rxBuf.slice(1);
|
|
109
|
-
throw new CrcError();
|
|
151
|
+
if (this.inFlight) {
|
|
152
|
+
throw new Error("Concurrent transact() calls are not supported");
|
|
153
|
+
}
|
|
154
|
+
this.inFlight = true;
|
|
155
|
+
try {
|
|
156
|
+
if (this.cfg.interRequestDelay > 0 && this.lastTransactionEnd !== null) {
|
|
157
|
+
const wait = this.lastTransactionEnd + this.cfg.interRequestDelay - this.now();
|
|
158
|
+
if (wait > 0) await sleep(wait);
|
|
110
159
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
160
|
+
await this.waitOutDirtyPeriod();
|
|
161
|
+
await this.writer.write(req);
|
|
162
|
+
const deadline = this.now() + this.cfg.timeout;
|
|
163
|
+
let State;
|
|
164
|
+
((State2) => {
|
|
165
|
+
State2[State2["TRY_EXTRACT"] = 0] = "TRY_EXTRACT";
|
|
166
|
+
State2[State2["HAVE_FRAME"] = 1] = "HAVE_FRAME";
|
|
167
|
+
State2[State2["BAD_CRC"] = 2] = "BAD_CRC";
|
|
168
|
+
State2[State2["NEED_READ"] = 3] = "NEED_READ";
|
|
169
|
+
})(State || (State = {}));
|
|
170
|
+
let state = 0 /* TRY_EXTRACT */;
|
|
171
|
+
let frame = null;
|
|
172
|
+
let discardedBytes = 0;
|
|
173
|
+
while (true) {
|
|
174
|
+
switch (state) {
|
|
175
|
+
case 0 /* TRY_EXTRACT */: {
|
|
176
|
+
frame = this.extractFrame();
|
|
177
|
+
if (!frame) {
|
|
178
|
+
state = 3 /* NEED_READ */;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
state = 1 /* HAVE_FRAME */;
|
|
182
|
+
}
|
|
183
|
+
case 1 /* HAVE_FRAME */: {
|
|
184
|
+
if (this.crcOk(frame)) {
|
|
185
|
+
if (!proveRequestResponseMismatch(req, frame)) return frame;
|
|
186
|
+
frame = null;
|
|
187
|
+
state = 0 /* TRY_EXTRACT */;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
state = 2 /* BAD_CRC */;
|
|
191
|
+
}
|
|
192
|
+
case 2 /* BAD_CRC */: {
|
|
193
|
+
if (this.CRC_STRICT) throw new CrcError();
|
|
194
|
+
discardedBytes += frame ? frame.length : 0;
|
|
195
|
+
frame = null;
|
|
196
|
+
if (discardedBytes >= this.MAX_RESYNC_DROPS) {
|
|
197
|
+
throw new ResyncError(discardedBytes, this.MAX_RESYNC_DROPS);
|
|
198
|
+
}
|
|
199
|
+
if (this.rxBuf.length > 0) {
|
|
200
|
+
state = 0 /* TRY_EXTRACT */;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
state = 3 /* NEED_READ */;
|
|
204
|
+
}
|
|
205
|
+
case 3 /* NEED_READ */: {
|
|
206
|
+
const now = this.now();
|
|
207
|
+
const remaining = deadline - now;
|
|
208
|
+
if (remaining <= 0) throw new TimeoutError();
|
|
209
|
+
const value = await this.readWithTimeout(remaining);
|
|
210
|
+
this.rxBuf = concat(this.rxBuf, value);
|
|
211
|
+
state = 0 /* TRY_EXTRACT */;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
throw new Error("invalid state");
|
|
216
|
+
}
|
|
116
217
|
}
|
|
218
|
+
} finally {
|
|
219
|
+
this.inFlight = false;
|
|
220
|
+
this.lastTransactionEnd = this.now();
|
|
117
221
|
}
|
|
118
222
|
}
|
|
223
|
+
async readOnce(maxMs) {
|
|
224
|
+
if (maxMs <= 0) return { kind: "timeout" };
|
|
225
|
+
const ms = Math.max(1, maxMs);
|
|
226
|
+
let timer;
|
|
227
|
+
try {
|
|
228
|
+
const result = await Promise.race([
|
|
229
|
+
this.reader.read(),
|
|
230
|
+
new Promise((resolve) => {
|
|
231
|
+
timer = setTimeout(() => resolve(_WebSerialTransport.TIMEOUT), ms);
|
|
232
|
+
})
|
|
233
|
+
]);
|
|
234
|
+
if (result === _WebSerialTransport.TIMEOUT)
|
|
235
|
+
return { kind: "timeout" };
|
|
236
|
+
if (result.done || !result.value) return { kind: "closed" };
|
|
237
|
+
return { kind: "chunk", value: result.value };
|
|
238
|
+
} finally {
|
|
239
|
+
if (timer) clearTimeout(timer);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async waitOutDirtyPeriod() {
|
|
243
|
+
for (; ; ) {
|
|
244
|
+
const now = this.now();
|
|
245
|
+
if (now >= this.dirtyUntil) return;
|
|
246
|
+
const remaining = this.dirtyUntil - now;
|
|
247
|
+
const r = await this.readOnce(remaining);
|
|
248
|
+
if (r.kind !== "chunk") break;
|
|
249
|
+
}
|
|
250
|
+
this.rxBuf = new Uint8Array(0);
|
|
251
|
+
}
|
|
252
|
+
async readWithTimeout(ms) {
|
|
253
|
+
const r = await this.readOnce(ms);
|
|
254
|
+
if (r.kind === "chunk") return r.value;
|
|
255
|
+
if (r.kind === "timeout") {
|
|
256
|
+
await this.resetReaderAfterTimeout();
|
|
257
|
+
this.rxBuf = new Uint8Array(0);
|
|
258
|
+
this.dirtyUntil = this.now() + this.cfg.postTimeoutWaitPeriod;
|
|
259
|
+
throw new TimeoutError();
|
|
260
|
+
}
|
|
261
|
+
if (r.kind === "closed") {
|
|
262
|
+
throw new StreamClosedError();
|
|
263
|
+
}
|
|
264
|
+
throw new Error("invalid read result");
|
|
265
|
+
}
|
|
266
|
+
async resetReaderAfterTimeout() {
|
|
267
|
+
try {
|
|
268
|
+
await this.reader.cancel();
|
|
269
|
+
} catch (e) {
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
this.reader.releaseLock();
|
|
273
|
+
} catch (e) {
|
|
274
|
+
}
|
|
275
|
+
this.reader = this.port.readable.getReader();
|
|
276
|
+
}
|
|
277
|
+
extractFrame() {
|
|
278
|
+
if (this.rxBuf.length < 3) return null;
|
|
279
|
+
const need = this.frameLengthIfComplete(this.rxBuf);
|
|
280
|
+
if (need === 0) return null;
|
|
281
|
+
const frame = this.rxBuf.slice(0, need);
|
|
282
|
+
this.rxBuf = this.rxBuf.slice(need);
|
|
283
|
+
return frame;
|
|
284
|
+
}
|
|
285
|
+
crcOk(frame) {
|
|
286
|
+
const crc = crc16(frame.subarray(0, frame.length - 2));
|
|
287
|
+
const crcOk = (crc & 255) === frame[frame.length - 2] && crc >> 8 === frame[frame.length - 1];
|
|
288
|
+
return crcOk;
|
|
289
|
+
}
|
|
290
|
+
now() {
|
|
291
|
+
return Date.now();
|
|
292
|
+
}
|
|
119
293
|
// ----------------------------------------------------------------
|
|
120
294
|
// Determine expected length; return 0 if we still need more bytes
|
|
121
295
|
// ----------------------------------------------------------------
|
|
122
296
|
frameLengthIfComplete(buf) {
|
|
123
297
|
if (buf.length < 3) return 0;
|
|
298
|
+
const id = buf[0];
|
|
124
299
|
const fc = buf[1];
|
|
300
|
+
if (id === 0 || id > 247) return 0;
|
|
125
301
|
if (fc & 128) return buf.length >= 5 ? 5 : 0;
|
|
126
302
|
if (fc === 1 || fc === 2 || fc === 3 || fc === 4) {
|
|
127
|
-
const
|
|
303
|
+
const byteCount = buf[2];
|
|
304
|
+
if (byteCount > 252) {
|
|
305
|
+
return buf.length >= 8 ? 8 : 0;
|
|
306
|
+
}
|
|
307
|
+
const need = 3 + byteCount + 2;
|
|
128
308
|
return buf.length >= need ? need : 0;
|
|
129
309
|
}
|
|
130
|
-
if (fc === 5 ||
|
|
310
|
+
if (fc === 5 || // write single coil
|
|
311
|
+
fc === 6 || // write single register
|
|
312
|
+
fc === 15 || // write multiple coils
|
|
313
|
+
fc === 16 || // write multiple registers
|
|
314
|
+
fc === 22)
|
|
131
315
|
return buf.length >= 8 ? 8 : 0;
|
|
132
316
|
return buf.length >= 8 ? 8 : 0;
|
|
133
317
|
}
|
|
134
|
-
// -- close port --
|
|
135
318
|
async close() {
|
|
136
319
|
var _a, _b, _c;
|
|
137
320
|
await ((_a = this.reader) == null ? void 0 : _a.cancel());
|
|
@@ -139,27 +322,100 @@ var WebSerialTransport = class _WebSerialTransport {
|
|
|
139
322
|
await ((_c = this.port) == null ? void 0 : _c.close());
|
|
140
323
|
}
|
|
141
324
|
};
|
|
325
|
+
_WebSerialTransport.TIMEOUT = Symbol("timeout");
|
|
326
|
+
var WebSerialTransport = _WebSerialTransport;
|
|
327
|
+
function proveRequestResponseMismatch(req, res) {
|
|
328
|
+
if (req.length < 2 || res.length < 2) return false;
|
|
329
|
+
const reqAddr = req[0];
|
|
330
|
+
const reqFc = req[1] & 127;
|
|
331
|
+
const resAddr = res[0];
|
|
332
|
+
const resFc = res[1];
|
|
333
|
+
const resBaseFc = resFc & 127;
|
|
334
|
+
if (resAddr !== reqAddr) return true;
|
|
335
|
+
if (resBaseFc !== reqFc) return true;
|
|
336
|
+
if (resFc & 128) {
|
|
337
|
+
return res.length !== 5;
|
|
338
|
+
}
|
|
339
|
+
switch (reqFc) {
|
|
340
|
+
case FC_READ_COILS:
|
|
341
|
+
case FC_READ_DISCRETE_INPUTS: {
|
|
342
|
+
if (req.length < 6 || res.length < 3) return false;
|
|
343
|
+
const quantity = u16be(req, 4);
|
|
344
|
+
const expectedByteCount = Math.ceil(quantity / 8);
|
|
345
|
+
if (res[2] !== expectedByteCount) return true;
|
|
346
|
+
if (res.length !== 3 + expectedByteCount + 2) return true;
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
case FC_READ_HOLDING_REGISTERS:
|
|
350
|
+
case FC_READ_INPUT_REGISTERS: {
|
|
351
|
+
if (req.length < 6 || res.length < 3) return false;
|
|
352
|
+
const quantity = u16be(req, 4);
|
|
353
|
+
const expectedByteCount = quantity * 2;
|
|
354
|
+
if (res[2] !== expectedByteCount) return true;
|
|
355
|
+
if (res.length !== 3 + expectedByteCount + 2) return true;
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
case FC_WRITE_SINGLE_COIL:
|
|
359
|
+
case FC_WRITE_SINGLE_HOLDING_REGISTER:
|
|
360
|
+
case FC_MASK_WRITE_REGISTER: {
|
|
361
|
+
if (req.length < 8 || res.length < 8) return false;
|
|
362
|
+
if (res.length !== req.length) return true;
|
|
363
|
+
if (!bytesEqual(req, 2, res, 2, req.length - 4)) return true;
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
case FC_WRITE_MULTIPLE_COILS:
|
|
367
|
+
case FC_WRITE_MULTIPLE_HOLDING_REGISTERS: {
|
|
368
|
+
if (req.length < 6 || res.length < 8) return false;
|
|
369
|
+
if (res.length !== 8) return true;
|
|
370
|
+
if (!bytesEqual(req, 2, res, 2, 4)) return true;
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
case FC_READ_WRITE_MULTIPLE_REGISTERS: {
|
|
374
|
+
if (req.length < 10 || res.length < 3) return false;
|
|
375
|
+
const readQuantity = u16be(req, 4);
|
|
376
|
+
const expectedByteCount = readQuantity * 2;
|
|
377
|
+
if (res[2] !== expectedByteCount) return true;
|
|
378
|
+
if (res.length !== 3 + expectedByteCount + 2) return true;
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
case FC_READ_FIFO_QUEUE: {
|
|
382
|
+
if (res.length < 6) return true;
|
|
383
|
+
const byteCount = u16be(res, 2);
|
|
384
|
+
if (byteCount < 2) return true;
|
|
385
|
+
if (res.length !== 4 + byteCount + 2) return true;
|
|
386
|
+
const fifoDataBytes = byteCount - 2;
|
|
387
|
+
if (fifoDataBytes % 2 !== 0) return true;
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
case FC_READ_FILE_RECORD:
|
|
391
|
+
case FC_WRITE_FILE_RECORD: {
|
|
392
|
+
if (res.length < 5) return true;
|
|
393
|
+
const byteCount = res[2];
|
|
394
|
+
if (res.length !== 3 + byteCount + 2) return true;
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
default:
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
142
401
|
function concat(a, b) {
|
|
143
402
|
const out = new Uint8Array(a.length + b.length);
|
|
144
403
|
out.set(a, 0);
|
|
145
404
|
out.set(b, a.length);
|
|
146
405
|
return out;
|
|
147
406
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
var FC_MASK_WRITE_REGISTER = 22;
|
|
161
|
-
var FC_READ_WRITE_MULTIPLE_REGISTERS = 23;
|
|
162
|
-
var FC_READ_FIFO_QUEUE = 24;
|
|
407
|
+
function u16be(buf, offset) {
|
|
408
|
+
return buf[offset] << 8 | buf[offset + 1];
|
|
409
|
+
}
|
|
410
|
+
function bytesEqual(a, aStart, b, bStart, len) {
|
|
411
|
+
for (let i = 0; i < len; i++) {
|
|
412
|
+
if (a[aStart + i] !== b[bStart + i]) return false;
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
async function sleep(ms) {
|
|
417
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
418
|
+
}
|
|
163
419
|
|
|
164
420
|
// src/core/frames.ts
|
|
165
421
|
function buildReadHolding(id, addr, len) {
|
|
@@ -595,10 +851,20 @@ var ModbusRTU = class _ModbusRTU {
|
|
|
595
851
|
return { data: parseReadFifoQueue(raw), raw };
|
|
596
852
|
}
|
|
597
853
|
};
|
|
854
|
+
|
|
855
|
+
// src/index.ts
|
|
856
|
+
var BUILD_INFO = {
|
|
857
|
+
version: "0.11.0",
|
|
858
|
+
commit: "0107e92",
|
|
859
|
+
buildTime: "2026-03-13T07:30:43.483Z"
|
|
860
|
+
};
|
|
598
861
|
export {
|
|
862
|
+
BUILD_INFO,
|
|
599
863
|
CrcError,
|
|
600
864
|
ExceptionError,
|
|
601
865
|
ModbusRTU,
|
|
866
|
+
ResyncError,
|
|
867
|
+
StreamClosedError,
|
|
602
868
|
TimeoutError,
|
|
603
869
|
buildMaskWriteRegister,
|
|
604
870
|
buildReadCoils,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modbus-webserial",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "ESM library for speaking Modbus-RTU from the browser via Web Serial",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Antti Kotajärvi <antti.kotajarvi@hotmail.com>",
|
|
7
7
|
"license": "MIT",
|