Skip to content

Commit 8e2b4a4

Browse files
authored
fix(mqtt-bridge): load devices from config and fix type safety (#352)
Fixes two critical issues in mqtt-bridge: 1. **loadConfig() missing devices field** - Device configurations were silently dropped when loading from config files due to missing schema field and reconstruction logic. 2. **Type safety mismatches** - Zod schemas didn't align with TypeScript interfaces under exactOptionalPropertyTypes, causing compilation failures. Fixed by adding transform functions to conditionally exclude undefined values. Changes: - Added devices field to loadConfig() schema and reconstruction - Made RTU serial parameters required with sensible defaults (parity: none, dataBits: 8, stopBits: 1) - Made TCP port optional (defaults to 502 in transport layer) - Added timeout field support for both connection types - Applied Zod transforms to handle optional fields correctly - Added 7 comprehensive tests covering all scenarios All 291 tests passing. TypeScript compilation successful. Closes #347 Closes #348
1 parent d16410d commit 8e2b4a4

4 files changed

Lines changed: 181 additions & 22 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,26 @@ describe('loadConfig', () => {
117117
topicPrefix: 'test',
118118
})
119119
})
120+
121+
it('should load devices from config file', async () => {
122+
const configJson = JSON.stringify({
123+
mqtt: { url: 'mqtt://localhost:1883' },
124+
devices: [
125+
{
126+
deviceId: 'test-device',
127+
driver: '@ya-modbus/driver-ex9em',
128+
connection: { type: 'rtu', port: '/dev/ttyUSB0', baudRate: 9600, slaveId: 1 },
129+
polling: { interval: 2000 },
130+
},
131+
],
132+
})
133+
134+
mockedReadFile.mockResolvedValue(configJson)
135+
const config = await loadConfig('/path/to/config.json')
136+
137+
expect(config.devices).toBeDefined()
138+
expect(config.devices).toHaveLength(1)
139+
expect(config.devices?.[0].deviceId).toBe('test-device')
140+
expect(config.devices?.[0].driver).toBe('@ya-modbus/driver-ex9em')
141+
})
120142
})

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

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

55
import type { MqttBridgeConfig } from '../types.js'
66

7+
import { deviceConfigSchema } from './device-validation.js'
8+
79
const mqttConfigSchema = z.object({
810
url: z
911
.string()
@@ -29,6 +31,7 @@ const mqttBridgeConfigSchema = z.object({
2931
'Topic prefix must not contain MQTT special characters (+, #, /, $) or null character',
3032
})
3133
.optional(),
34+
devices: z.array(deviceConfigSchema).optional(),
3235
})
3336

3437
export async function loadConfig(configPath: string): Promise<MqttBridgeConfig> {
@@ -56,6 +59,7 @@ export async function loadConfig(configPath: string): Promise<MqttBridgeConfig>
5659
},
5760
...(result.data.stateDir !== undefined && { stateDir: result.data.stateDir }),
5861
...(result.data.topicPrefix !== undefined && { topicPrefix: result.data.topicPrefix }),
62+
...(result.data.devices !== undefined && { devices: result.data.devices }),
5963
}
6064

6165
return config

packages/mqtt-bridge/src/utils/device-validation.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,4 +380,104 @@ describe('validateDeviceConfig', () => {
380380

381381
expect(() => validateDeviceConfig(config)).toThrow('Invalid device configuration')
382382
})
383+
384+
describe('RTU connection defaults', () => {
385+
it('should apply defaults for omitted RTU serial parameters', () => {
386+
const config = {
387+
deviceId: 'device1',
388+
driver: 'ya-modbus-driver-test',
389+
connection: {
390+
type: 'rtu',
391+
port: '/dev/ttyUSB0',
392+
baudRate: 9600,
393+
slaveId: 1,
394+
// parity, dataBits, stopBits omitted - should get defaults
395+
},
396+
}
397+
398+
expect(() => validateDeviceConfig(config)).not.toThrow()
399+
})
400+
401+
it('should accept explicit RTU serial parameters', () => {
402+
const config = {
403+
deviceId: 'device1',
404+
driver: 'ya-modbus-driver-test',
405+
connection: {
406+
type: 'rtu',
407+
port: '/dev/ttyUSB0',
408+
baudRate: 9600,
409+
slaveId: 1,
410+
parity: 'even',
411+
dataBits: 7,
412+
stopBits: 2,
413+
},
414+
}
415+
416+
expect(() => validateDeviceConfig(config)).not.toThrow()
417+
})
418+
419+
it('should accept timeout for RTU connection', () => {
420+
const config = {
421+
deviceId: 'device1',
422+
driver: 'ya-modbus-driver-test',
423+
connection: {
424+
type: 'rtu',
425+
port: '/dev/ttyUSB0',
426+
baudRate: 9600,
427+
slaveId: 1,
428+
timeout: 5000,
429+
},
430+
}
431+
432+
expect(() => validateDeviceConfig(config)).not.toThrow()
433+
})
434+
})
435+
436+
describe('TCP connection optional port', () => {
437+
it('should accept TCP connection without port (defaults to 502)', () => {
438+
const config = {
439+
deviceId: 'device1',
440+
driver: 'ya-modbus-driver-test',
441+
connection: {
442+
type: 'tcp',
443+
host: 'localhost',
444+
slaveId: 1,
445+
// port omitted - should default to 502
446+
},
447+
}
448+
449+
expect(() => validateDeviceConfig(config)).not.toThrow()
450+
})
451+
452+
it('should accept TCP connection with explicit port', () => {
453+
const config = {
454+
deviceId: 'device1',
455+
driver: 'ya-modbus-driver-test',
456+
connection: {
457+
type: 'tcp',
458+
host: 'localhost',
459+
port: 8502,
460+
slaveId: 1,
461+
},
462+
}
463+
464+
expect(() => validateDeviceConfig(config)).not.toThrow()
465+
})
466+
467+
it('should accept timeout for TCP connection', () => {
468+
const config = {
469+
deviceId: 'device1',
470+
driver: 'ya-modbus-driver-test',
471+
connection: {
472+
type: 'tcp',
473+
host: 'localhost',
474+
port: 502,
475+
slaveId: 1,
476+
timeout: 3000,
477+
},
478+
}
479+
480+
expect(() => validateDeviceConfig(config)).not.toThrow()
481+
})
482+
})
383483
})

packages/mqtt-bridge/src/utils/device-validation.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,38 @@ const driverNameSchema = z
2626
message: 'Driver name cannot contain path traversal sequences',
2727
})
2828

29-
const rtuConnectionSchema = z.object({
30-
type: z.literal('rtu'),
31-
port: z.string().min(1),
32-
baudRate: z.number().positive(),
33-
slaveId: z.number().int().min(0).max(247),
34-
parity: z.enum(['none', 'even', 'odd']).optional(),
35-
dataBits: z.union([z.literal(7), z.literal(8)]).optional(),
36-
stopBits: z.union([z.literal(1), z.literal(2)]).optional(),
37-
})
29+
const rtuConnectionSchema = z
30+
.object({
31+
type: z.literal('rtu'),
32+
port: z.string().min(1),
33+
baudRate: z.number().positive(),
34+
slaveId: z.number().int().min(0).max(247),
35+
parity: z.enum(['none', 'even', 'odd']).default('none'),
36+
dataBits: z.union([z.literal(7), z.literal(8)]).default(8),
37+
stopBits: z.union([z.literal(1), z.literal(2)]).default(1),
38+
timeout: z.number().int().positive().optional(),
39+
})
40+
.transform((data) => {
41+
const { timeout, ...rest } = data
42+
return timeout !== undefined ? { ...rest, timeout } : rest
43+
})
3844

39-
const tcpConnectionSchema = z.object({
40-
type: z.literal('tcp'),
41-
host: z.string().min(1),
42-
port: z.number().int().positive().max(65535),
43-
slaveId: z.number().int().min(0).max(247),
44-
})
45+
const tcpConnectionSchema = z
46+
.object({
47+
type: z.literal('tcp'),
48+
host: z.string().min(1),
49+
port: z.number().int().positive().max(65535).optional(),
50+
slaveId: z.number().int().min(0).max(247),
51+
timeout: z.number().int().positive().optional(),
52+
})
53+
.transform((data) => {
54+
const { port, timeout, ...rest } = data
55+
return {
56+
...rest,
57+
...(port !== undefined ? { port } : {}),
58+
...(timeout !== undefined ? { timeout } : {}),
59+
}
60+
})
4561

4662
const pollingConfigSchema = z
4763
.object({
@@ -59,15 +75,32 @@ const pollingConfigSchema = z
5975
.optional(),
6076
retryBackoff: z.number().int().positive().optional(),
6177
})
78+
.transform((data) => {
79+
const { maxRetries, retryBackoff, ...rest } = data
80+
return {
81+
...rest,
82+
...(maxRetries !== undefined ? { maxRetries } : {}),
83+
...(retryBackoff !== undefined ? { retryBackoff } : {}),
84+
}
85+
})
6286
.optional()
6387

64-
export const deviceConfigSchema = z.object({
65-
deviceId: deviceIdSchema,
66-
driver: driverNameSchema,
67-
connection: z.union([rtuConnectionSchema, tcpConnectionSchema]),
68-
enabled: z.boolean().optional(),
69-
polling: pollingConfigSchema,
70-
})
88+
export const deviceConfigSchema = z
89+
.object({
90+
deviceId: deviceIdSchema,
91+
driver: driverNameSchema,
92+
connection: z.union([rtuConnectionSchema, tcpConnectionSchema]),
93+
enabled: z.boolean().optional(),
94+
polling: pollingConfigSchema,
95+
})
96+
.transform((data) => {
97+
const { enabled, polling, ...rest } = data
98+
return {
99+
...rest,
100+
...(enabled !== undefined ? { enabled } : {}),
101+
...(polling !== undefined ? { polling } : {}),
102+
}
103+
})
71104

72105
export function validateDeviceConfig(config: unknown): void {
73106
const result = deviceConfigSchema.safeParse(config)

0 commit comments

Comments
 (0)