Guide for creating third-party device drivers for ya-modbus.
Device drivers are distributed as npm packages that:
- Implement the driver interface from
@ya-modbus/driver-sdk - Define semantic data points (not raw registers)
- Can be loaded dynamically by the bridge
- Are tested independently using provided tooling
Monorepo packages: @ya-modbus/driver-<name> (scoped naming)
- Directory:
packages/driver-<name> - Package name:
@ya-modbus/driver-<name>
Third-party packages: ya-modbus-driver-<name> or @org/ya-modbus-driver-<name>
- Makes drivers easily discoverable
- Consistent with ecosystem conventions
# Create new npm package with recommended naming
mkdir ya-modbus-driver-solar
cd ya-modbus-driver-solar
npm init -y
# Install SDK (production dependency)
npm install @ya-modbus/driver-sdk
# Install dev dependencies
npm install --save-dev @ya-modbus/emulator typescript @types/nodemy-modbus-driver/
├── package.json
├── tsconfig.json
└── src/
├── index.ts # Driver export
├── device.ts # Driver implementation
└── device.test.ts # Tests
Single package can export multiple device types:
- Related devices from same manufacturer
- Device family with shared logic but different capabilities
- Multiple firmware versions with different register layouts
See packages/driver-*/ for reference implementations.
Required: Implement DeviceDriver interface from @ya-modbus/driver-types.
Key responsibilities:
- Define data point catalog (semantic names, units, types)
- Transform raw Modbus registers to/from standard data types
- Declare device constraints (forbidden ranges, batch limits)
- Handle device-specific quirks (auth sequences, delays)
Example multi-device package (functional approach):
// src/index.ts - Single factory function for all device types
export const createDriver = async (config) => {
const { device, transport, slaveId } = config
// Auto-detect device type if not specified
const detectedType = device || (await detectDeviceType(transport, slaveId))
// Return device-specific implementation
switch (detectedType) {
case 'X1000':
return { name: 'SolarInverterX1000', maxPower: 1000 /* ... */ }
case 'X2000':
return { name: 'SolarInverterX2000', maxPower: 2000 /* ... */ }
case 'X5000':
return { name: 'SolarInverterX5000', maxPower: 5000 /* ... */ }
default:
throw new Error(`Unsupported device type: ${detectedType}`)
}
}
// DEVICES registry - provides metadata for CLI tools
export const DEVICES = {
X1000: {
manufacturer: 'Acme Corp',
model: 'X1000',
description: '1kW Solar Inverter',
},
X2000: {
manufacturer: 'Acme Corp',
model: 'X2000',
description: '2kW Solar Inverter',
},
X5000: {
manufacturer: 'Acme Corp',
model: 'X5000',
description: '5kW Solar Inverter',
},
}
// Auto-detection helper (reads device identification registers)
const detectDeviceType = async (transport, slaveId) => {
// Read model register and determine type
const modelId = await readRegister(transport, slaveId, 0x9000)
return modelId === 1 ? 'X1000' : modelId === 2 ? 'X2000' : 'X5000'
}Benefits: Single entry point, optional auto-detection, simpler configuration.
Exports for multi-device drivers:
createDriver- Factory function (required)DEFAULT_CONFIG- Connection defaults (recommended)SUPPORTED_CONFIG- Valid parameter ranges (recommended)DEVICES- Device registry with metadata (recommended for multi-device drivers)
# Test with emulator (fast iteration)
npm test
# Test with real device (in driver directory)
npx ya-modbus read --port /dev/ttyUSB0 --slave-id 1 \
--data-point voltage_l1
# Characterize device (discover capabilities)
npx ya-modbus scan-registers --port /dev/ttyUSB0 --slave-id 1
npx ya-modbus discover --port /dev/ttyUSB0
npx ya-modbus characterize --port /dev/ttyUSB0 --slave-id 1 \
--output device-profile.jsonNote: Development commands use local CLI from devDependencies (via npx). No need to specify driver - current directory is used.
Single factory function per package that handles all device types:
import type { DeviceDriver, DataPoint } from '@ya-modbus/driver-types'
// Single factory function - handles device type selection
export const createDriver = async (config): Promise<DeviceDriver> => {
const { device, transport, slaveId } = config
// Auto-detect if type not specified
const type = device || (await autoDetectDeviceType(transport, slaveId))
// Return driver configuration for detected type
return {
name: `my-device-${type}`,
manufacturer: 'Acme Corp',
model: type,
dataPoints: getDataPointsForType(type),
decodeDataPoint: (id, rawValue) => decode(type, id, rawValue),
encodeDataPoint: (id, value) => encode(type, id, value),
constraints: getConstraintsForType(type),
initialize: async () => initializeDevice(type, transport, slaveId),
}
}
// Auto-detection reads device identification registers
const autoDetectDeviceType = async (transport, slaveId) => {
const modelReg = await readRegister(transport, slaveId, 0x9000)
return modelReg === 1 ? 'ModelA' : 'ModelB'
}Key principles:
- Single entry point: One
createDriverfunction per package - Optional device type: Auto-detect if not specified
- Functional: Composable, testable, simpler than classes
- Type-specific logic: Internal implementation detail
External API (what users configure):
- Semantic data point IDs:
"voltage_l1","total_energy" - Standard units:
V,A,W,kWh - Standard types:
float,integer,boolean,timestamp
Internal Implementation (driver responsibility):
- Raw register addresses:
0x0000,0x0006 - Wire formats:
uint16,int32,float32,BCD - Multipliers, offsets, custom decoders
Driver owns the transformation layer - consumers never see raw registers.
See packages/driver-*/src/ for complete examples.
import { ModbusEmulator } from '@ya-modbus/emulator'
import { createDriver } from './device'
describe('MyDevice', () => {
let emulator: ModbusEmulator
beforeEach(async () => {
emulator = new ModbusEmulator({
devices: [
{
slaveId: 1,
registers: {
0x0000: 0x43664000, // 230.5 as float32
0x9000: 1, // Model ID for auto-detection
},
},
],
})
})
afterEach(() => emulator.stop())
it('should read voltage with explicit device type', async () => {
const driver = await createDriver({
device: 'ModelA',
slaveId: 1,
transport: emulator.getTransport(),
})
const value = await driver.readDataPoint('voltage_l1')
expect(value).toBeCloseTo(230.5, 1)
})
it('should auto-detect device type', async () => {
const driver = await createDriver({
// No device - will auto-detect
slaveId: 1,
transport: emulator.getTransport(),
})
expect(driver.model).toBe('ModelA')
})
})# Development testing (in driver directory, uses local deps)
npx ya-modbus read --port /dev/ttyUSB0 --slave-id 1 \
--data-point voltage_l1 --format json
# Production testing (CLI installed globally)
ya-modbus read --driver my-driver --port /dev/ttyUSB0 \
--slave-id 1 --data-point voltage_l1Use characterization tools to discover device capabilities:
# Auto-detect baud rate, parity, stop bits, slave ID
npx ya-modbus discover --port /dev/ttyUSB0Output: Connection parameters and device identification.
# Find readable register ranges
npx ya-modbus scan-registers --port /dev/ttyUSB0 --slave-id 1 \
--start 0 --end 10000 --type holding
# Find write-protected registers
npx ya-modbus scan-registers --port /dev/ttyUSB0 --slave-id 1 \
--test-writes --start 0 --end 100Output: Valid ranges, forbidden ranges, access restrictions.
# Test maximum batch read size
npx ya-modbus test-limits --port /dev/ttyUSB0 --slave-id 1
# Test minimum timing requirements
npx ya-modbus test-timing --port /dev/ttyUSB0 --slave-id 1Output: Max registers per read, min inter-command delay.
# Run all characterization tests
npx ya-modbus characterize --port /dev/ttyUSB0 --slave-id 1 \
--output device-profile.jsonOutput (device-profile.json):
{
"connection": {
"baudRate": 9600,
"parity": "none",
"stopBits": 1,
"slaveId": 1
},
"limits": {
"maxReadRegisters": 80,
"maxWriteRegisters": 60,
"minCommandDelay": 50
},
"readableRanges": [
{ "type": "holding", "start": 0, "end": 200 },
{ "type": "input", "start": 0, "end": 50 }
],
"forbiddenRanges": [{ "type": "holding", "start": 1000, "end": 1099, "reason": "Exception 2" }],
"accessRestrictions": {
"readProtected": [{ "address": 500, "note": "Write-only config" }],
"writeProtected": [{ "address": 100, "note": "Read-only serial" }],
"requiresAuth": {
"unlockRegister": 9999,
"protectedRange": { "start": 1000, "end": 1099 }
}
},
"quirks": [
{
"type": "write-delay-required",
"register": 200,
"minDelayMs": 100,
"reason": "EEPROM commit"
}
]
}Use this output to:
- Configure device constraints in driver
- Validate device documentation
- Debug communication issues
- Document device quirks
Declare device-specific limits and restrictions in driver object:
export const createMyDevice = (config) => ({
// ... other properties
constraints: {
// Modbus operation limits
maxReadRegisters: 80,
maxWriteRegisters: 60,
// Forbidden ranges (read/write blocked)
forbiddenRanges: [
{
type: 'holding' as const,
start: 1000,
end: 1099,
reason: 'Protected configuration area',
},
],
// Timing requirements
minCommandDelay: 50, // milliseconds
},
})Bridge enforces these constraints automatically.
Handle device-specific behaviors:
export const createMyDevice = (config) => ({
// ... other properties
initialize: async () => {
// Write password to unlock protected registers
await writeRegister(9999, Buffer.from('PASSWORD'))
},
})export const createMyDevice = (config) => {
const writeConfig = async (value) => {
// Write value
await writeRegister(200, value)
// Trigger EEPROM commit
await writeRegister(201, 1)
// Wait for commit (device-specific delay)
await sleep(100)
}
return {
// ... other properties
writeConfig,
}
}Document in driver implementation, enforce via function composition.
{
"name": "@ya-modbus/driver-solar",
"version": "1.0.0",
"description": "Modbus drivers for Acme Solar inverters (X1000, X2000, X5000 series)",
"keywords": ["ya-modbus-driver", "modbus", "solar", "inverter"],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"@ya-modbus/driver-sdk": "^1.0.0"
},
"devDependencies": {
"@ya-modbus/emulator": "^1.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@ya-modbus/driver-sdk": "^1.0.0"
},
"engines": {
"node": ">=20.0.0"
}
}Naming conventions:
- Monorepo:
@ya-modbus/driver-<name>(e.g.,@ya-modbus/driver-ex9em) - Third-party:
ya-modbus-driver-<name>or@org/ya-modbus-driver-<name> - Any name works if
keywordsincludes"ya-modbus-driver"
Required fields:
keywordsmust include"ya-modbus-driver"for discoverypeerDependenciesdeclares SDK compatibilityenginesfield with Node.js version requirement (e.g.,"node": ">=20.0.0")descriptionshould list supported device types if multiple
Benefits of single factory approach:
- Single import, simpler API
- Auto-detection when possible
- Easier configuration (no need to specify class name)
- Shared code and dependencies
- Consistent behavior across device family
See packages/driver-types/src/data-types.ts for complete list.
Common types:
float- Floating-point measurementsinteger- Whole numbersboolean- True/false statestimestamp- ISO 8601 timestampsstring- Text values
See packages/driver-types/src/units.ts for complete list.
Common units:
- Electrical:
V,A,W,kW,VA,kVA,kWh - Frequency:
Hz - Temperature:
°C,°F,K - Percentage:
%
The @ya-modbus/driver-sdk package provides codec functions for common transformation patterns:
Reading scaled integer values:
import { readScaledUInt16BE, readScaledInt16BE, readScaledUInt32BE } from '@ya-modbus/driver-sdk'
// Temperature stored as uint16 ×10 (235 = 23.5°C)
const buffer = await transport.readInputRegisters(0, 1)
const temperature = readScaledUInt16BE(buffer, 0, 10) // 23.5
// Correction offset stored as int16 ×10 (-50 = -5.0°C)
const corrBuffer = await transport.readHoldingRegisters(0x103, 1)
const correction = readScaledInt16BE(corrBuffer, 0, 10) // -5.0
// Total energy stored as uint32 ×100 (1000000 = 10000.00 kWh)
const energyBuffer = await transport.readHoldingRegisters(0x0007, 2)
const totalEnergy = readScaledUInt32BE(energyBuffer, 0, 100) // 10000.0Writing scaled integer values:
import { writeScaledUInt16BE, writeScaledInt16BE } from '@ya-modbus/driver-sdk'
// Write humidity correction of 5.5% (stored as 55)
const humBuffer = writeScaledUInt16BE(5.5, 10)
await transport.writeMultipleRegisters(0x104, humBuffer)
// Write temperature correction of -3.5°C (stored as -35)
const tempBuffer = writeScaledInt16BE(-3.5, 10)
await transport.writeMultipleRegisters(0x103, tempBuffer)Edge case handling:
All codec functions validate inputs and throw descriptive errors:
// Throws: Invalid scale: must be greater than 0
readScaledUInt16BE(buffer, 0, 0)
// Throws: Invalid value: must be a finite number
writeScaledUInt16BE(NaN, 10)
// Throws: Invalid scaled value: 65536 is outside uint16 range (0 to 65535)
writeScaledUInt16BE(6553.6, 10)See packages/driver-sdk/README.md for complete API reference.
| Device Encoding | Raw Buffer | Decoded Value | SDK Function |
|---|---|---|---|
| uint16 × 10 | 0x00EB |
23.5 (float) |
readScaledUInt16BE(..., 10) |
| int16 × 10 | 0xFFCE |
-5.0 (float) |
readScaledInt16BE(..., 10) |
| uint32 × 100 | 0x000F4240 |
10000.0 (float) |
readScaledUInt32BE(..., 100) |
| float32 BE | 0x43664000 |
230.5 (float) |
Custom decoder |
| Decimal YYMMDD | 0x03D3EC |
"2025-12-20" (timestamp) |
Custom decoder |
| BCD | 0x1234 |
1234 (integer) |
Custom decoder |
- ✅ All tests pass
- ✅ Tested with real device
- ✅ Documentation complete
- ✅ Package metadata correct
- ✅ License specified (GPL-3.0-or-later compatible)
npm publishUsers install driver alongside bridge:
# Install bridge
npm install -g ya-modbus
# Install driver (supports multiple device types)
npm install -g ya-modbus-driver-solar
# Use in configuration
{
"devices": [
{
"id": "inverter_1",
"driver": "ya-modbus-driver-solar",
"device": "X1000", // Optional: auto-detect if omitted
"transport": "tcp",
"host": "192.168.1.100"
},
{
"id": "inverter_2",
"driver": "ya-modbus-driver-solar",
"device": "X2000", // Explicit type
"transport": "tcp",
"host": "192.168.1.101"
},
{
"id": "inverter_3",
"driver": "ya-modbus-driver-solar",
// No device - will auto-detect
"transport": "tcp",
"host": "192.168.1.102"
}
]
}Configuration format:
driver: Package name only (e.g.,ya-modbus-driver-solar)device: Optional, specifies which device variant (useya-modbus list-devicesto see available devices)- Auto-detection: Omit
deviceand driver will detect device model - Single package handles entire device family
Write tests first using emulator, then implement driver.
See: CONTRIBUTING.md for TDD workflow.
Define data points users understand, not register addresses:
✅ Good: "voltage_l1", "total_energy", "operating_mode"
❌ Bad: "reg_0000", "holding_6", "input_register_12"
Use canonical units from driver-sdk:
✅ Good: V, A, kWh
❌ Bad: volts, amps, kilowatt_hours
Non-obvious behaviors belong in code comments and README.
Keep driver packages lightweight - avoid unnecessary dependencies.
Drivers and SDK follow semantic versioning independently:
SDK version (e.g., @ya-modbus/driver-sdk@2.3.1):
- Major: Breaking interface changes
- Minor: New features, backward compatible
- Patch: Bug fixes
Driver version (e.g., @acme/modbus-driver-solar@1.2.0):
- Major: Breaking changes to data points or behavior
- Minor: New data points, backward compatible
- Patch: Bug fixes
Compatibility: Declare in peerDependencies:
{
"peerDependencies": {
"@ya-modbus/driver-sdk": "^2.0.0"
}
}Bridge validates SDK compatibility at runtime.
Bridge can't load driver package.
Check:
- Driver installed?
npm list -g @acme/modbus-driver-solar keywordsincludes"ya-modbus-driver"?- Package exports driver correctly?
TypeScript compilation fails.
Check:
@ya-modbus/driver-sdkversion matchespeerDependencies- TypeScript version compatible
tsconfig.jsonextends SDK base config
Tests fail with emulator but work with real device.
Check:
- Emulator register values match expected format
- Emulator configured for correct transport (RTU/TCP)
- Test cleanup (stop emulator in
afterEach)
Tests pass with emulator but fail with real device.
Check:
- Device constraints accurate (max batch size, timing)
- Device quirks handled (auth, delays, read order)
- Characterization tool results match implementation
Complete driver examples in monorepo:
packages/driver-xymd1/src/device.ts- Temperature/humidity sensor reference implementationpackages/driver-ex9em/src/device.ts- Energy meter with multiple data points- See other
packages/driver-*/for additional examples
Study these for patterns and conventions.
- API Reference:
packages/driver-sdk/docs/ - Type Definitions:
packages/driver-types/index.d.ts - Emulator:
packages/emulator/for testing without hardware - Architecture:
docs/ARCHITECTURE.md - Contributing:
CONTRIBUTING.md
- GitHub Issues: Bug reports for SDK/tools
- GitHub Discussions: Questions about driver development
- Examples: Reference implementations in
packages/driver-*/