Skip to content

Commit 28e800e

Browse files
authored
feat(mqtt-bridge): add device loading from config (#341)
Add support for loading devices from configuration files in mqtt-bridge CLI. This enables declarative device configuration for E2E testing, Docker/Kubernetes deployments, and simple CLI usage without requiring programmatic device addition. Changes: - Add optional devices array to MqttBridgeConfig interface - Extend config validator to validate devices array using deviceConfigSchema - Load devices sequentially after bridge starts in CLI - Add comprehensive configuration examples to README Benefits: - Eliminates wrapper scripts for E2E testing - Simplifies Docker/K8s deployments with declarative config - Better user experience with single config file - Perfect backward compatibility (devices field is optional) Closes #337
1 parent 5f02d90 commit 28e800e

6 files changed

Lines changed: 318 additions & 1 deletion

File tree

packages/mqtt-bridge/README.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,32 @@ Create a `config.json` file:
103103
"username": "user",
104104
"password": "pass"
105105
},
106-
"topicPrefix": "modbus"
106+
"topicPrefix": "modbus",
107+
"devices": [
108+
{
109+
"deviceId": "sensor1",
110+
"driver": "@ya-modbus/driver-ex9em",
111+
"connection": {
112+
"type": "rtu",
113+
"port": "/dev/ttyUSB0",
114+
"baudRate": 9600,
115+
"slaveId": 1
116+
},
117+
"polling": {
118+
"interval": 2000
119+
}
120+
},
121+
{
122+
"deviceId": "meter1",
123+
"driver": "@ya-modbus/driver-xymd1",
124+
"connection": {
125+
"type": "tcp",
126+
"host": "192.168.1.100",
127+
"port": 502,
128+
"slaveId": 2
129+
}
130+
}
131+
]
107132
}
108133
```
109134

@@ -116,6 +141,38 @@ Create a `config.json` file:
116141
- `mqtt.reconnectPeriod` (optional) - Reconnection interval in milliseconds (default: 5000)
117142
- `topicPrefix` (optional) - Topic prefix for all MQTT topics (default: 'modbus')
118143
- `stateDir` (optional) - Directory path for state persistence (future)
144+
- `devices` (optional) - Array of device configurations to load on startup
145+
146+
**Device configuration:**
147+
148+
- `deviceId` (required) - Unique device identifier
149+
- `driver` (required) - Driver package name (@ya-modbus/driver-_ or ya-modbus-driver-_)
150+
- `connection` (required) - Connection configuration (RTU or TCP)
151+
- `enabled` (optional) - Enable/disable device (default: true)
152+
- `polling` (optional) - Polling configuration
153+
154+
**RTU connection:**
155+
156+
- `type: "rtu"` (required)
157+
- `port` (required) - Serial port path
158+
- `baudRate` (required) - Baud rate (e.g., 9600, 19200)
159+
- `slaveId` (required) - Modbus slave ID (0-247)
160+
- `parity` (optional) - Parity: "none", "even", "odd"
161+
- `dataBits` (optional) - Data bits: 7 or 8
162+
- `stopBits` (optional) - Stop bits: 1 or 2
163+
164+
**TCP connection:**
165+
166+
- `type: "tcp"` (required)
167+
- `host` (required) - TCP host address
168+
- `port` (required) - TCP port (1-65535)
169+
- `slaveId` (required) - Modbus slave ID (0-247)
170+
171+
**Polling configuration:**
172+
173+
- `interval` (required) - Polling interval in milliseconds (100-86400000)
174+
- `maxRetries` (optional) - Maximum retry attempts (0-100)
175+
- `retryBackoff` (optional) - Retry backoff in milliseconds
119176

120177
## Programmatic Usage
121178

packages/mqtt-bridge/src/cli.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,150 @@ describe('CLI - ya-modbus-bridge', () => {
368368
})
369369
})
370370

371+
describe('Device Loading from Config', () => {
372+
it('should load devices from config after bridge starts', async () => {
373+
const mockConfig = {
374+
mqtt: {
375+
url: 'mqtt://localhost:1883',
376+
},
377+
devices: [
378+
{
379+
deviceId: 'device1',
380+
driver: '@ya-modbus/driver-ex9em',
381+
connection: {
382+
type: 'rtu',
383+
port: '/dev/ttyUSB0',
384+
baudRate: 9600,
385+
slaveId: 1,
386+
},
387+
polling: {
388+
interval: 2000,
389+
},
390+
},
391+
],
392+
}
393+
394+
mockBridge.addDevice = jest.fn().mockResolvedValue(undefined)
395+
396+
jest.mocked(configModule.loadConfig).mockResolvedValue(mockConfig)
397+
398+
await program.parseAsync(['node', 'ya-modbus-bridge', 'run', '--config', 'config.json'])
399+
400+
expect(mockBridge.start).toHaveBeenCalled()
401+
expect(mockBridge.addDevice).toHaveBeenCalledWith(mockConfig.devices[0])
402+
})
403+
404+
it('should load multiple devices from config', async () => {
405+
const mockConfig = {
406+
mqtt: {
407+
url: 'mqtt://localhost:1883',
408+
},
409+
devices: [
410+
{
411+
deviceId: 'rtu-device',
412+
driver: '@ya-modbus/driver-ex9em',
413+
connection: {
414+
type: 'rtu',
415+
port: '/dev/ttyUSB0',
416+
baudRate: 9600,
417+
slaveId: 1,
418+
},
419+
},
420+
{
421+
deviceId: 'tcp-device',
422+
driver: '@ya-modbus/driver-xymd1',
423+
connection: {
424+
type: 'tcp',
425+
host: 'localhost',
426+
port: 502,
427+
slaveId: 2,
428+
},
429+
},
430+
],
431+
}
432+
433+
mockBridge.addDevice = jest.fn().mockResolvedValue(undefined)
434+
435+
jest.mocked(configModule.loadConfig).mockResolvedValue(mockConfig)
436+
437+
await program.parseAsync(['node', 'ya-modbus-bridge', 'run', '--config', 'config.json'])
438+
439+
expect(mockBridge.start).toHaveBeenCalled()
440+
expect(mockBridge.addDevice).toHaveBeenCalledTimes(2)
441+
expect(mockBridge.addDevice).toHaveBeenCalledWith(mockConfig.devices[0])
442+
expect(mockBridge.addDevice).toHaveBeenCalledWith(mockConfig.devices[1])
443+
})
444+
445+
it('should not call addDevice when config has no devices array', async () => {
446+
const mockConfig = {
447+
mqtt: {
448+
url: 'mqtt://localhost:1883',
449+
},
450+
}
451+
452+
mockBridge.addDevice = jest.fn().mockResolvedValue(undefined)
453+
454+
jest.mocked(configModule.loadConfig).mockResolvedValue(mockConfig)
455+
456+
await program.parseAsync(['node', 'ya-modbus-bridge', 'run', '--config', 'config.json'])
457+
458+
expect(mockBridge.start).toHaveBeenCalled()
459+
expect(mockBridge.addDevice).not.toHaveBeenCalled()
460+
})
461+
462+
it('should not call addDevice when devices array is empty', async () => {
463+
const mockConfig = {
464+
mqtt: {
465+
url: 'mqtt://localhost:1883',
466+
},
467+
devices: [],
468+
}
469+
470+
mockBridge.addDevice = jest.fn().mockResolvedValue(undefined)
471+
472+
jest.mocked(configModule.loadConfig).mockResolvedValue(mockConfig)
473+
474+
await program.parseAsync(['node', 'ya-modbus-bridge', 'run', '--config', 'config.json'])
475+
476+
expect(mockBridge.start).toHaveBeenCalled()
477+
expect(mockBridge.addDevice).not.toHaveBeenCalled()
478+
})
479+
480+
it('should handle device loading errors gracefully', async () => {
481+
const mockConfig = {
482+
mqtt: {
483+
url: 'mqtt://localhost:1883',
484+
},
485+
devices: [
486+
{
487+
deviceId: 'device1',
488+
driver: '@ya-modbus/driver-ex9em',
489+
connection: {
490+
type: 'rtu',
491+
port: '/dev/ttyUSB0',
492+
baudRate: 9600,
493+
slaveId: 1,
494+
},
495+
},
496+
],
497+
}
498+
499+
mockBridge.addDevice = jest.fn().mockRejectedValue(new Error('Failed to load driver'))
500+
501+
jest.mocked(configModule.loadConfig).mockResolvedValue(mockConfig)
502+
503+
await program.parseAsync(['node', 'ya-modbus-bridge', 'run', '--config', 'config.json'])
504+
505+
expect(mockBridge.start).toHaveBeenCalled()
506+
expect(mockBridge.addDevice).toHaveBeenCalled()
507+
expect(consoleErrorSpy).toHaveBeenCalledWith(
508+
expect.stringContaining('Error:'),
509+
'Failed to load driver'
510+
)
511+
expect(processUtils.exit).toHaveBeenCalledWith(1)
512+
})
513+
})
514+
371515
describe('Signal Handlers', () => {
372516
it('should register SIGINT handler', async () => {
373517
const mockConfig = {

packages/mqtt-bridge/src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ async function runCommand(options: RunCommandOptions): Promise<void> {
9999

100100
console.log(chalk.green('Bridge started successfully'))
101101

102+
// Load devices from config if provided
103+
if (config.devices && config.devices.length > 0) {
104+
console.log(chalk.blue(`Loading ${config.devices.length} device(s) from configuration...`))
105+
for (const deviceConfig of config.devices) {
106+
await bridge.addDevice(deviceConfig)
107+
console.log(chalk.green(`✓ Loaded device: ${deviceConfig.deviceId}`))
108+
}
109+
}
110+
102111
let isShuttingDown = false
103112
const handleShutdown = async (signal: string): Promise<void> => {
104113
if (isShuttingDown) {

packages/mqtt-bridge/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface MqttBridgeConfig {
88
}
99
stateDir?: string
1010
topicPrefix?: string
11+
devices?: DeviceConfig[]
1112
}
1213

1314
export interface BridgeStatus {

packages/mqtt-bridge/src/utils/config-validator.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,107 @@ describe('validateConfig', () => {
6666

6767
expect(() => validateConfig(config)).toThrow('Invalid configuration')
6868
})
69+
70+
it('should validate config with devices array', () => {
71+
const config = {
72+
mqtt: {
73+
url: 'mqtt://localhost:1883',
74+
},
75+
devices: [
76+
{
77+
deviceId: 'device1',
78+
driver: '@ya-modbus/driver-ex9em',
79+
connection: {
80+
type: 'rtu',
81+
port: '/dev/ttyUSB0',
82+
baudRate: 9600,
83+
slaveId: 1,
84+
},
85+
polling: {
86+
interval: 2000,
87+
},
88+
},
89+
],
90+
}
91+
92+
expect(() => validateConfig(config)).not.toThrow()
93+
})
94+
95+
it('should validate config with multiple devices', () => {
96+
const config = {
97+
mqtt: {
98+
url: 'mqtt://localhost:1883',
99+
},
100+
devices: [
101+
{
102+
deviceId: 'rtu-device',
103+
driver: '@ya-modbus/driver-ex9em',
104+
connection: {
105+
type: 'rtu',
106+
port: '/dev/ttyUSB0',
107+
baudRate: 9600,
108+
slaveId: 1,
109+
},
110+
},
111+
{
112+
deviceId: 'tcp-device',
113+
driver: '@ya-modbus/driver-xymd1',
114+
connection: {
115+
type: 'tcp',
116+
host: 'localhost',
117+
port: 502,
118+
slaveId: 2,
119+
},
120+
},
121+
],
122+
}
123+
124+
expect(() => validateConfig(config)).not.toThrow()
125+
})
126+
127+
it('should throw error for invalid device config in devices array', () => {
128+
const config = {
129+
mqtt: {
130+
url: 'mqtt://localhost:1883',
131+
},
132+
devices: [
133+
{
134+
deviceId: '',
135+
driver: '@ya-modbus/driver-ex9em',
136+
connection: {
137+
type: 'rtu',
138+
port: '/dev/ttyUSB0',
139+
baudRate: 9600,
140+
slaveId: 1,
141+
},
142+
},
143+
],
144+
}
145+
146+
expect(() => validateConfig(config)).toThrow('Invalid configuration')
147+
expect(() => validateConfig(config)).toThrow('deviceId')
148+
})
149+
150+
it('should throw error for invalid driver name in devices array', () => {
151+
const config = {
152+
mqtt: {
153+
url: 'mqtt://localhost:1883',
154+
},
155+
devices: [
156+
{
157+
deviceId: 'device1',
158+
driver: 'invalid-driver',
159+
connection: {
160+
type: 'rtu',
161+
port: '/dev/ttyUSB0',
162+
baudRate: 9600,
163+
slaveId: 1,
164+
},
165+
},
166+
],
167+
}
168+
169+
expect(() => validateConfig(config)).toThrow('Invalid configuration')
170+
expect(() => validateConfig(config)).toThrow('driver')
171+
})
69172
})

packages/mqtt-bridge/src/utils/config-validator.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { z } from 'zod'
22

33
import type { MqttBridgeConfig } from '../types.js'
44

5+
import { deviceConfigSchema } from './device-validation.js'
6+
57
const mqttConfigSchema = z.object({
68
url: z
79
.string()
@@ -26,6 +28,7 @@ const mqttBridgeConfigSchema = z.object({
2628
'Topic prefix must not contain MQTT special characters (+, #, /, $) or null character',
2729
})
2830
.optional(),
31+
devices: z.array(deviceConfigSchema).optional(),
2932
})
3033

3134
export function validateConfig(config: unknown): asserts config is MqttBridgeConfig {

0 commit comments

Comments
 (0)