Skip to content

Commit 3e2a2f1

Browse files
authored
feat(emulator): add configurable lock option for RTU transport (#263)
Add optional `lock` configuration to RTU transport, allowing users to control serial port locking behavior. Defaults to true (exclusive locking enabled) for production security, can be disabled for testing with virtual serial ports. **Core Implementation:** - Add lock?: boolean to RtuTransportConfig, EmulatorConfig, and ConfigFile interfaces - Pass lock option to ServerSerial third parameter with secure default (lock ?? true) - Wire lock option through full stack: CLI → Config → Emulator → RTU Transport **CLI & UX:** - Add --no-lock flag following standard Commander.js pattern - Consistent with other boolean flags (--verbose, --quiet) - Support configuration via CLI args, config files, and programmatic API **Documentation:** - Add comprehensive documentation to README.md with troubleshooting - Update virtual-serial-test.md with lock: false examples and "Cannot lock port" troubleshooting - Enhance config file examples (basic-rtu.yaml, multi-device.yaml) with clear guidance **Testing:** - Unit tests: default behavior, explicit true, explicit false, combination with all serial parameters - Integration tests: full stack passthrough verification (lock: false, lock: true, default) - Mock consistency: aligned across unit and integration test files - 1543 tests passing, 95%+ coverage maintained **Security:** - Secure by default: lock: true prevents multi-process port conflicts - Hardware protection: exclusive locking prevents communication corruption - Explicit opt-out required: users must consciously disable for testing - Clear documentation distinguishes production vs. testing usage Closes #251 Closes #264 Closes #265
1 parent 7a96066 commit 3e2a2f1

12 files changed

Lines changed: 268 additions & 49 deletions

File tree

packages/emulator/README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ const emulator = new ModbusEmulator({
9999
parity: 'none',
100100
dataBits: 8,
101101
stopBits: 1,
102+
// lock: true, // Enable exclusive port locking (default)
102103
})
103104

104105
// Add devices
@@ -150,6 +151,7 @@ Options:
150151
-H, --host <host> TCP host address (default: 0.0.0.0)
151152
-b, --baud-rate <rate> Serial baud rate (default: 9600)
152153
--parity <type> Serial parity: none|even|odd (default: none)
154+
--no-lock Disable serial port locking (enabled by default)
153155
-s, --slave-id <id> Slave ID (required if no config file)
154156
-v, --verbose Enable verbose logging
155157
-q, --quiet Suppress all output except errors
@@ -248,20 +250,33 @@ For testing without physical hardware, create virtual serial port pairs:
248250
socat -d -d pty,raw,echo=0 pty,raw,echo=0
249251
# Note the output: /dev/pts/3 <-> /dev/pts/4
250252
251-
# Terminal 2: Start emulator on first port
252-
ya-modbus-emulator --transport rtu --port /dev/pts/3 --slave-id 1
253+
# Terminal 2: Start emulator on first port (disable locking for virtual ports)
254+
ya-modbus-emulator --transport rtu --port /dev/pts/3 --slave-id 1 --no-lock
253255
254256
# Terminal 3: Run your driver tests against second port
255257
node test-driver.js --port /dev/pts/4
256258
```
257259

260+
> **Note:** Virtual serial ports created by `socat` don't properly support exclusive locking.
261+
> Use `--no-lock` flag or set `lock: false` in config files when using virtual ports.
262+
258263
**Windows** (using com0com):
259264

260265
1. Install [com0com](https://sourceforge.net/projects/com0com/)
261266
2. Create a pair: COM10 <-> COM11
262-
3. Start emulator: `ya-modbus-emulator --transport rtu --port COM10 --slave-id 1`
267+
3. Start emulator: `ya-modbus-emulator --transport rtu --port COM10 --slave-id 1 --no-lock`
263268
4. Run tests: `node test-driver.js --port COM11`
264269

270+
**Troubleshooting:**
271+
272+
If you encounter `Error: Resource temporarily unavailable Cannot lock port`:
273+
274+
- Add `--no-lock` flag to the CLI command, OR
275+
- Set `lock: false` in your config file's transport section
276+
277+
This error occurs with virtual serial ports (socat PTYs, com0com) that don't properly reset
278+
exclusive locks. For production use with real serial hardware, keep locking enabled (default).
279+
265280
See `examples/virtual-serial-test.md` for detailed virtual serial port testing guide.
266281

267282
## Configuration

packages/emulator/examples/config-files/basic-rtu.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ transport:
66
port: /dev/ttyUSB0 # Linux/macOS, use COM3 on Windows
77
baudRate: 9600
88
parity: none
9+
# lock: true # Exclusive port locking enabled by default (set to false for virtual ports)
910

1011
devices:
1112
- slaveId: 1

packages/emulator/examples/config-files/multi-device.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

44
transport:
55
type: rtu
6-
port: /dev/pts/10
6+
port: /dev/pts/10 # Virtual serial port (socat PTY)
77
baudRate: 9600
8+
lock: false # Required for virtual ports - they don't support TIOCEXCL properly
89

910
devices:
1011
# Power meter (slave 1)

packages/emulator/examples/virtual-serial-test.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const emulator = new ModbusEmulator({
5353
port: '/tmp/vserial0', // Linux/macOS, use COM3 on Windows
5454
baudRate: 9600,
5555
parity: 'none',
56+
lock: false, // Disable locking for virtual ports (socat PTYs)
5657
})
5758

5859
// Add a simulated power meter device
@@ -288,6 +289,40 @@ sudo usermod -a -G dialout $USER
288289
sudo chmod 666 /tmp/vserial0
289290
```
290291

292+
### Cannot Lock Port Error
293+
294+
```
295+
Error: Resource temporarily unavailable Cannot lock port
296+
```
297+
298+
**Cause:** Virtual serial ports created by `socat` don't properly support exclusive locking (TIOCEXCL).
299+
300+
**Solution:** Disable port locking for virtual ports:
301+
302+
```typescript
303+
// In code
304+
const emulator = new ModbusEmulator({
305+
transport: 'rtu',
306+
port: '/tmp/vserial0',
307+
lock: false, // Add this
308+
})
309+
```
310+
311+
```bash
312+
# Or via CLI
313+
ya-modbus-emulator --transport rtu --port /tmp/vserial0 --slave-id 1 --no-lock
314+
```
315+
316+
```yaml
317+
# Or in config file
318+
transport:
319+
type: rtu
320+
port: /tmp/vserial0
321+
lock: false
322+
```
323+
324+
> **Important:** Only disable locking for virtual ports. Keep it enabled (default) for real serial hardware in production.
325+
291326
### CRC Errors
292327
293328
- Verify both ends use same baud rate

packages/emulator/src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface CliOptions {
1616
host?: string
1717
baudRate?: number
1818
parity?: string
19+
lock?: boolean
1920
slaveId?: number
2021
verbose?: boolean
2122
quiet?: boolean
@@ -36,6 +37,7 @@ program
3637
.option('-H, --host <host>', 'TCP host address (default: 0.0.0.0)')
3738
.option('-b, --baud-rate <rate>', 'Serial baud rate (default: 9600)', parseInt)
3839
.option('--parity <type>', 'Serial parity: none|even|odd (default: none)')
40+
.option('--no-lock', 'Disable serial port locking (enabled by default)')
3941
.option('-s, --slave-id <id>', 'Slave ID (required if no config file)', parseInt)
4042
.option('-v, --verbose', 'Enable verbose logging')
4143
.option('-q, --quiet', 'Suppress all output except errors')
@@ -75,6 +77,7 @@ async function main(): Promise<void> {
7577
...(fileConfig.transport.stopBits !== undefined && {
7678
stopBits: fileConfig.transport.stopBits,
7779
}),
80+
...(fileConfig.transport.lock !== undefined && { lock: fileConfig.transport.lock }),
7881
}
7982

8083
devices = fileConfig.devices as Array<{ slaveId: number }>
@@ -98,6 +101,7 @@ async function main(): Promise<void> {
98101
...(options.parity !== undefined && {
99102
parity: options.parity as 'none' | 'even' | 'odd',
100103
}),
104+
...(typeof options.lock === 'boolean' && { lock: options.lock }),
101105
}
102106

103107
devices = [{ slaveId: options.slaveId }]

packages/emulator/src/emulator.rtu.integration.test.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ let capturedServiceVector: any = null
1919
// Mock modbus-serial
2020
jest.mock('modbus-serial', () => {
2121
return {
22-
ServerSerial: jest.fn().mockImplementation((vector: any, options: any) => {
23-
// Capture service vector for testing
24-
capturedServiceVector = vector
25-
26-
// Call openCallback immediately to simulate successful connection
27-
if (options.openCallback) {
28-
setImmediate(() => options.openCallback(null))
29-
}
30-
31-
return {
32-
close: jest.fn((cb: (err: Error | null) => void) => cb(null)),
33-
on: jest.fn(),
34-
socks: new Map(),
35-
}
36-
}),
22+
ServerSerial: jest
23+
.fn()
24+
.mockImplementation((vector: any, options: any, _serialportOptions?: any) => {
25+
// Capture service vector for testing
26+
capturedServiceVector = vector
27+
28+
// Call openCallback immediately to simulate successful connection
29+
if (options.openCallback) {
30+
setImmediate(() => options.openCallback(null))
31+
}
32+
33+
return {
34+
close: jest.fn((cb: (err: Error | null) => void) => cb(null)),
35+
on: jest.fn(),
36+
socks: new Map(),
37+
}
38+
}),
3739
}
3840
})
3941

@@ -227,6 +229,66 @@ describe('ModbusEmulator RTU Integration', () => {
227229
dataBits: 8,
228230
stopBits: 1,
229231
unitID: 255,
232+
}),
233+
expect.any(Object)
234+
)
235+
})
236+
237+
it('should pass lock: false through full stack', async () => {
238+
const { ServerSerial } = await import('modbus-serial')
239+
240+
emulator = new ModbusEmulator({
241+
transport: 'rtu',
242+
port: '/dev/ttyUSB0',
243+
lock: false,
244+
})
245+
246+
await emulator.start()
247+
248+
expect(ServerSerial).toHaveBeenCalledWith(
249+
expect.any(Object),
250+
expect.any(Object),
251+
expect.objectContaining({
252+
lock: false,
253+
})
254+
)
255+
})
256+
257+
it('should pass lock: true through full stack', async () => {
258+
const { ServerSerial } = await import('modbus-serial')
259+
260+
emulator = new ModbusEmulator({
261+
transport: 'rtu',
262+
port: '/dev/ttyUSB0',
263+
lock: true,
264+
})
265+
266+
await emulator.start()
267+
268+
expect(ServerSerial).toHaveBeenCalledWith(
269+
expect.any(Object),
270+
expect.any(Object),
271+
expect.objectContaining({
272+
lock: true,
273+
})
274+
)
275+
})
276+
277+
it('should default to lock: true when not specified', async () => {
278+
const { ServerSerial } = await import('modbus-serial')
279+
280+
emulator = new ModbusEmulator({
281+
transport: 'rtu',
282+
port: '/dev/ttyUSB0',
283+
})
284+
285+
await emulator.start()
286+
287+
expect(ServerSerial).toHaveBeenCalledWith(
288+
expect.any(Object),
289+
expect.any(Object),
290+
expect.objectContaining({
291+
lock: true,
230292
})
231293
)
232294
})

packages/emulator/src/emulator.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ describe('ModbusEmulator', () => {
124124
} as any)
125125
}).toThrow('RTU transport requires port')
126126
})
127+
128+
it('should create RTU transport with lock option', () => {
129+
emulator = new ModbusEmulator({
130+
transport: 'rtu',
131+
port: '/dev/pts/10',
132+
lock: false,
133+
})
134+
expect(emulator).toBeDefined()
135+
expect(emulator.getTransport()).toBeDefined()
136+
})
127137
})
128138

129139
describe('request handling', () => {

packages/emulator/src/emulator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ export class ModbusEmulator {
2929
parity?: 'none' | 'even' | 'odd'
3030
dataBits?: 7 | 8
3131
stopBits?: 1 | 2
32+
lock?: boolean
3233
} = {
3334
port: config.port,
3435
...(config.baudRate !== undefined && { baudRate: config.baudRate }),
3536
...(config.parity !== undefined && { parity: config.parity }),
3637
...(config.dataBits !== undefined && { dataBits: config.dataBits }),
3738
...(config.stopBits !== undefined && { stopBits: config.stopBits }),
39+
...(config.lock !== undefined && { lock: config.lock }),
3840
}
3941
this.transport = new RtuTransport(rtuConfig)
4042
} else if (config.transport === 'tcp') {

0 commit comments

Comments
 (0)