Skip to content

Commit 0aadf5a

Browse files
authored
feat(device-profiler): add JSON output format (#377)
Add JSON output format to device-profiler CLI tool, enabling machine-readable output for automation and integration with other tools. Users can now choose between table (default) and JSON output formats using the --format or -f flag. Features: - CLI option: --format/-f with choices table/json (default: table) - JSON formatter with numeric register values (0-65535) - Progress suppression in JSON mode - Comprehensive documentation with examples - 100% test coverage for JSON formatter Implementation: - Register values as 16-bit unsigned integers using readUInt16BE(0) - Big-endian byte order (Modbus standard) - Direct numeric access, no parsing required - Maintains backward compatibility (table format default) All 120 tests passing with 98%+ coverage. Closes #372
1 parent 229a2f7 commit 0aadf5a

7 files changed

Lines changed: 675 additions & 34 deletions

File tree

packages/device-profiler/README.md

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Device profiler for discovering Modbus register maps through automated scanning.
1010
- Timing measurement for each read operation
1111
- Error classification (timeout, CRC, Modbus exceptions)
1212
- Summary table of discovered registers
13+
- **JSON output format** for automation and integration
1314

1415
## Installation
1516

@@ -22,17 +23,27 @@ npm install @ya-modbus/device-profiler
2223
### CLI
2324

2425
```bash
26+
# Table output (default)
2527
ya-modbus-profile --port /dev/ttyUSB0 --slave-id 1 --baud 9600
28+
29+
# JSON output for automation
30+
ya-modbus-profile --port /dev/ttyUSB0 --slave-id 1 --format json
2631
```
2732

2833
Options:
2934

30-
- `--port` - Serial port or TCP host:port
31-
- `--slave-id` - Modbus slave ID
32-
- `--baud` - Baud rate for RTU (default: 9600)
33-
- `--start` - Start register address (default: 0)
34-
- `--end` - End register address (default: 100)
35-
- `--batch` - Batch size for reads (default: 10)
35+
- `--port` - Serial port (e.g., `/dev/ttyUSB0`) or TCP host:port (e.g., `localhost:502`)
36+
- `--slave-id` - Modbus slave ID (1-247)
37+
- `--type` - Register type: `holding` or `input` (default: `holding`)
38+
- `--start` - Start register address (default: `0`)
39+
- `--end` - End register address (default: `100`)
40+
- `--batch` - Batch size for reads (default: `10`)
41+
- `--baud` - Baud rate for RTU (default: `9600`)
42+
- `--parity` - Parity for RTU: `none`, `even`, `odd` (default: `none`)
43+
- `--data-bits` - Data bits for RTU (default: `8`)
44+
- `--stop-bits` - Stop bits for RTU (default: `1`)
45+
- `--timeout` - Response timeout in milliseconds (default: `1000`)
46+
- `-f, --format` - Output format: `table` or `json` (default: `table`)
3647

3748
### Programmatic
3849

@@ -56,14 +67,134 @@ await scanRegisters({
5667

5768
## Output
5869

59-
The scanner produces a summary showing:
70+
### Table Format (Default)
71+
72+
The scanner produces a summary table showing:
6073

6174
- Register address
6275
- Register type (holding/input)
6376
- Read success/failure
64-
- Response time
77+
- Register value (hex-encoded)
78+
- Response time (milliseconds)
6579
- Error details (if any)
6680

81+
Example:
82+
83+
```
84+
Scanning holding registers from 0 to 10...
85+
86+
Progress: 11/11 (100%)
87+
88+
Scan complete!
89+
90+
┌─────────┬─────────┬─────────┬───────┬────────┬───────┐
91+
│ Address │ Type │ Status │ Value │ Timing │ Error │
92+
├─────────┼─────────┼─────────┼───────┼────────┼───────┤
93+
│ 0 │ holding │ ✓ │ 1234 │ 15ms │ │
94+
│ 1 │ holding │ ✗ │ │ 1000ms │ Timeout waiting for response │
95+
│ 2 │ holding │ ✓ │ 5678 │ 12ms │ │
96+
└─────────┴─────────┴─────────┴───────┴────────┴───────┘
97+
```
98+
99+
### JSON Format
100+
101+
For automation and integration with other tools, use `--format json` to output machine-readable JSON:
102+
103+
```bash
104+
ya-modbus-profile --port /dev/ttyUSB0 --slave-id 1 --format json
105+
```
106+
107+
#### JSON Structure
108+
109+
The JSON output includes:
110+
111+
- **`timestamp`** (string): ISO 8601 timestamp when the scan completed
112+
- **`scan`** (object): Scan configuration
113+
- `type` (string): Register type (`"holding"` or `"input"`)
114+
- `startAddress` (number): Starting register address
115+
- `endAddress` (number): Ending register address (inclusive)
116+
- `batchSize` (number): Batch size used for reading
117+
- **`connection`** (object): Connection details
118+
- `port` (string): Serial port path or TCP host:port
119+
- **`results`** (array): Array of scan results, one per register
120+
- `address` (number): Register address
121+
- `type` (string): Register type (`"holding"` or `"input"`)
122+
- `success` (boolean): Whether the read succeeded
123+
- `value` (number | null): 16-bit register value (0-65535) or `null` if failed
124+
- `timing` (number): Read operation duration in milliseconds
125+
- `error` (string, optional): Error message (only present when `success=false`)
126+
- `errorType` (string, optional): Error classification (only present when `success=false`)
127+
- `"timeout"`: Device did not respond within timeout period
128+
- `"crc"`: CRC check failed (data corruption)
129+
- `"modbus_exception"`: Device returned a Modbus exception
130+
- `"unknown"`: Other errors
131+
- **`summary`** (object): Scan statistics
132+
- `total` (number): Total registers scanned
133+
- `successful` (number): Number of successful reads
134+
- `failed` (number): Number of failed reads
135+
- `totalTimeMs` (number): Total scan duration in milliseconds
136+
- `averageTimeMs` (number): Average read time per register in milliseconds
137+
138+
#### JSON Example
139+
140+
```json
141+
{
142+
"timestamp": "2026-02-13T18:17:41.367Z",
143+
"scan": {
144+
"type": "holding",
145+
"startAddress": 0,
146+
"endAddress": 10,
147+
"batchSize": 10
148+
},
149+
"connection": {
150+
"port": "/dev/ttyUSB0"
151+
},
152+
"results": [
153+
{
154+
"address": 0,
155+
"type": "holding",
156+
"success": true,
157+
"value": 4660,
158+
"timing": 15
159+
},
160+
{
161+
"address": 1,
162+
"type": "holding",
163+
"success": false,
164+
"value": null,
165+
"timing": 1000,
166+
"error": "Timeout waiting for response",
167+
"errorType": "timeout"
168+
},
169+
{
170+
"address": 2,
171+
"type": "holding",
172+
"success": true,
173+
"value": 22136,
174+
"timing": 12
175+
}
176+
],
177+
"summary": {
178+
"total": 3,
179+
"successful": 2,
180+
"failed": 1,
181+
"totalTimeMs": 1027,
182+
"averageTimeMs": 342.33
183+
}
184+
}
185+
```
186+
187+
#### JSON Output Notes
188+
189+
- **Progress messages** are suppressed in JSON mode for clean output
190+
- **Register values** are 16-bit unsigned integers (0-65535) in big-endian format
191+
- Direct numeric access, no parsing needed
192+
- Example: bytes `[0x12, 0x34]` = 4660 decimal (0x1234 hex)
193+
- Format as hex if needed: `value.toString(16).padStart(4, '0')`
194+
- **Error fields** (`error`, `errorType`) are only included when `success=false`
195+
- **Timing precision** is milliseconds with up to 2 decimal places
196+
- **Output is formatted** with 2-space indentation for readability
197+
67198
## License
68199

69200
GPL-3.0-or-later

packages/device-profiler/src/cli.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Transport } from '@ya-modbus/driver-types'
22

33
import { runProfileScan } from './cli.js'
4+
import * as jsonFormatterModule from './json-formatter.js'
45
import { RegisterType } from './read-tester.js'
56

67
jest.mock('chalk')
78
jest.mock('cli-table3')
9+
jest.mock('./json-formatter.js')
810

911
describe('runProfileScan', () => {
1012
let mockConsoleLog: jest.SpyInstance
@@ -121,4 +123,106 @@ describe('runProfileScan', () => {
121123

122124
expect(mockTransport.close).toHaveBeenCalled()
123125
})
126+
127+
describe('JSON output format', () => {
128+
it('should output JSON when format is json', async () => {
129+
const mockFormatJSON = jest.fn().mockReturnValue('{"test": "json"}')
130+
;(jsonFormatterModule.formatJSON as jest.Mock) = mockFormatJSON
131+
132+
const mockTransport: Transport = {
133+
readHoldingRegisters: jest.fn().mockResolvedValue(Buffer.from([0x12, 0x34])),
134+
close: jest.fn().mockResolvedValue(undefined),
135+
} as unknown as Transport
136+
137+
await runProfileScan({
138+
transport: mockTransport,
139+
type: RegisterType.Holding,
140+
startAddress: 0,
141+
endAddress: 0,
142+
format: 'json',
143+
port: '/dev/ttyUSB0',
144+
})
145+
146+
expect(mockFormatJSON).toHaveBeenCalled()
147+
expect(mockConsoleLog).toHaveBeenCalledWith('{"test": "json"}')
148+
expect(mockTransport.close).toHaveBeenCalled()
149+
})
150+
151+
it('should suppress progress output when format is json', async () => {
152+
const mockFormatJSON = jest.fn().mockReturnValue('{}')
153+
;(jsonFormatterModule.formatJSON as jest.Mock) = mockFormatJSON
154+
155+
const mockStdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation()
156+
157+
const mockTransport: Transport = {
158+
readHoldingRegisters: jest.fn().mockResolvedValue(Buffer.from([0x00, 0x00])),
159+
close: jest.fn().mockResolvedValue(undefined),
160+
} as unknown as Transport
161+
162+
await runProfileScan({
163+
transport: mockTransport,
164+
type: RegisterType.Holding,
165+
startAddress: 0,
166+
endAddress: 5,
167+
format: 'json',
168+
port: 'localhost:502',
169+
})
170+
171+
// Progress should not be written to stdout in JSON mode
172+
expect(mockStdoutWrite).not.toHaveBeenCalled()
173+
174+
mockStdoutWrite.mockRestore()
175+
})
176+
177+
it('should output table when format is table (default)', async () => {
178+
const mockTransport: Transport = {
179+
readHoldingRegisters: jest.fn().mockResolvedValue(Buffer.from([0x00, 0x00])),
180+
close: jest.fn().mockResolvedValue(undefined),
181+
} as unknown as Transport
182+
183+
await runProfileScan({
184+
transport: mockTransport,
185+
type: RegisterType.Holding,
186+
startAddress: 0,
187+
endAddress: 0,
188+
format: 'table',
189+
port: '/dev/ttyUSB0',
190+
})
191+
192+
const logOutput = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
193+
expect(logOutput).toContain('Address')
194+
expect(mockTransport.close).toHaveBeenCalled()
195+
})
196+
197+
it('should pass correct metadata to JSON formatter', async () => {
198+
const mockFormatJSON = jest.fn().mockReturnValue('{}')
199+
;(jsonFormatterModule.formatJSON as jest.Mock) = mockFormatJSON
200+
201+
const mockTransport: Transport = {
202+
readInputRegisters: jest.fn().mockResolvedValue(Buffer.from([0xab, 0xcd])),
203+
close: jest.fn().mockResolvedValue(undefined),
204+
} as unknown as Transport
205+
206+
await runProfileScan({
207+
transport: mockTransport,
208+
type: RegisterType.Input,
209+
startAddress: 100,
210+
endAddress: 200,
211+
batchSize: 25,
212+
format: 'json',
213+
port: 'localhost:502',
214+
})
215+
216+
expect(mockFormatJSON).toHaveBeenCalledWith(
217+
expect.any(Array),
218+
expect.objectContaining({
219+
type: RegisterType.Input,
220+
startAddress: 100,
221+
endAddress: 200,
222+
batchSize: 25,
223+
port: 'localhost:502',
224+
})
225+
)
226+
})
227+
})
124228
})

packages/device-profiler/src/cli.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import type { Transport } from '@ya-modbus/driver-types'
66

77
import { formatProgress, formatSummary } from './console-formatter.js'
8-
import { PROGRESS_UPDATE_INTERVAL_MS } from './constants.js'
8+
import { DEFAULT_BATCH_SIZE, PROGRESS_UPDATE_INTERVAL_MS } from './constants.js'
9+
import { formatJSON } from './json-formatter.js'
910
import { RegisterType } from './read-tester.js'
1011
import { scanRegisters, type ScanResult } from './register-scanner.js'
1112

@@ -23,6 +24,10 @@ export interface ProfileScanOptions {
2324
endAddress: number
2425
/** Maximum registers to read in a single batch (default: 10) */
2526
batchSize?: number
27+
/** Output format: table or json (default: table) */
28+
format?: 'table' | 'json'
29+
/** Connection port (for JSON metadata) */
30+
port?: string
2631
}
2732

2833
/**
@@ -31,12 +36,15 @@ export interface ProfileScanOptions {
3136
* @param options - Scan configuration
3237
*/
3338
export async function runProfileScan(options: ProfileScanOptions): Promise<void> {
34-
const { transport, type, startAddress, endAddress, batchSize } = options
39+
const { transport, type, startAddress, endAddress, batchSize, format = 'table', port } = options
3540
const results: ScanResult[] = []
41+
const isJsonFormat = format === 'json'
3642

3743
try {
38-
console.log(`Scanning ${type} registers from ${startAddress} to ${endAddress}...`)
39-
console.log()
44+
if (!isJsonFormat) {
45+
console.log(`Scanning ${type} registers from ${startAddress} to ${endAddress}...`)
46+
console.log()
47+
}
4048

4149
let lastProgressUpdate = 0
4250
await scanRegisters({
@@ -46,21 +54,35 @@ export async function runProfileScan(options: ProfileScanOptions): Promise<void>
4654
endAddress,
4755
...(batchSize !== undefined && { batchSize }),
4856
onProgress: (current, total) => {
49-
const now = Date.now()
50-
if (now - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL_MS || current === total) {
51-
process.stdout.write(`\r${formatProgress(current, total)}`)
52-
lastProgressUpdate = now
57+
if (!isJsonFormat) {
58+
const now = Date.now()
59+
if (now - lastProgressUpdate >= PROGRESS_UPDATE_INTERVAL_MS || current === total) {
60+
process.stdout.write(`\r${formatProgress(current, total)}`)
61+
lastProgressUpdate = now
62+
}
5363
}
5464
},
5565
onResult: (result) => {
5666
results.push(result)
5767
},
5868
})
5969

60-
console.log('\n')
61-
console.log('Scan complete!')
62-
console.log()
63-
console.log(formatSummary(results))
70+
if (isJsonFormat) {
71+
console.log(
72+
formatJSON(results, {
73+
type,
74+
startAddress,
75+
endAddress,
76+
batchSize: batchSize ?? DEFAULT_BATCH_SIZE,
77+
port: port ?? '',
78+
})
79+
)
80+
} else {
81+
console.log('\n')
82+
console.log('Scan complete!')
83+
console.log()
84+
console.log(formatSummary(results))
85+
}
6486
} finally {
6587
await transport.close()
6688
}

0 commit comments

Comments
 (0)