diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index dac300d..d6b2696 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -6,7 +6,6 @@
-
diff --git a/README.md b/README.md
index 6d0f775..a6a158a 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ to any number of different targets, such as databases or MQTT.
* Supports _**multiple different power sensors**_
* [IotaWatt](http://iotawatt.com/)
* [Shelly](https://www.shelly.com/) (both Gen 1 and Gen 2)
- * Generic Modbus sensors (limited support)
+ * Generic Modbus sensors
* Supports _**virtual power sensors**_
* A virtual power sensor gets its values from other configured sensors, enabling the user to calculate the total
power usage of three-phase devices or three-phase mains power
diff --git a/examples/config.sample.full.yml b/examples/config.sample.full.yml
index cc5f8a0..c491b9b 100644
--- a/examples/config.sample.full.yml
+++ b/examples/config.sample.full.yml
@@ -146,8 +146,7 @@ circuits:
address: 10.112.4.250
port: 502
unit: 100
- register: 866
- type: int16
+ register: h@866/int16 # same as just 866
filters:
clamp: positive
diff --git a/package-lock.json b/package-lock.json
index cef3bed..b47cf5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4373,12 +4373,13 @@
}
},
"node_modules/micromatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
- "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "braces": "^3.0.2",
+ "braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
diff --git a/src/config.ts b/src/config.ts
index b93b3bd..54978b8 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -7,7 +7,7 @@ import {
getCharacteristicsSensorData as getIotawattCharacteristicsSensorData,
getSensorData as getIotawattSensorData,
} from './sensor/iotawatt'
-import { getSensorData as getModbusSensorData } from './sensor/modbus'
+import { DEFAULT_PORT, DEFAULT_UNIT, getSensorData as getModbusSensorData } from './sensor/modbus'
import { getSensorData as getVirtualSensorData } from './sensor/virtual'
import { getSensorData as getUnmeteredSensorData } from './sensor/unmetered'
import {
@@ -16,6 +16,7 @@ import {
} from './sensor/dummy'
import {
CharacteristicsSensorType,
+ ModbusSensor,
SensorType,
ShellySensor,
ShellyType,
@@ -28,6 +29,7 @@ import { InfluxDBPublisher, InfluxDBPublisherImpl } from './publisher/influxdb'
import { ConsolePublisher, ConsolePublisherImpl } from './publisher/console'
import { Characteristics } from './characteristics'
import { MqttPublisher, MqttPublisherImpl } from './publisher/mqtt'
+import { parseRegisterDefinition } from './modbus/register'
type MilliSeconds = number
@@ -89,6 +91,17 @@ export const resolveAndValidateConfig = (config: Config): Config => {
}
}
+ if (circuit.sensor.type === SensorType.Modbus) {
+ // Set sane defaults for Modbus sensors
+ const modbusSensor = circuit.sensor as ModbusSensor
+ if (modbusSensor.modbus.port === undefined) {
+ modbusSensor.modbus.port = DEFAULT_PORT
+ }
+ if (modbusSensor.modbus.unit === undefined) {
+ modbusSensor.modbus.unit = DEFAULT_UNIT
+ }
+ }
+
// Sensors are not hidden by default
if (circuit.hidden === undefined) {
circuit.hidden = false
@@ -145,6 +158,15 @@ export const resolveAndValidateConfig = (config: Config): Config => {
}
}
+ // Parse Modbus register definitions
+ for (const circuit of config.circuits) {
+ if (circuit.sensor.type === SensorType.Modbus) {
+ const modbusSensor = circuit.sensor as ModbusSensor
+
+ modbusSensor.modbus.register = parseRegisterDefinition(modbusSensor.modbus.register as string)
+ }
+ }
+
// Attach poll functions to circuit sensors
for (const circuit of config.circuits) {
switch (circuit.sensor.type) {
diff --git a/src/modbus/register.ts b/src/modbus/register.ts
new file mode 100644
index 0000000..13bbde2
--- /dev/null
+++ b/src/modbus/register.ts
@@ -0,0 +1,98 @@
+export enum RegisterType {
+ HOLDING_REGISTER = 'h',
+ INPUT_REGISTER = 'i',
+ COIL = 'c',
+ DISCRETE_INPUT = 'd',
+}
+
+const dataTypes = ['int16', 'uint16', 'int32', 'uint32', 'boolean', 'float']
+export type DataType = (typeof dataTypes)[number]
+
+export type ModbusRegister = {
+ registerType: RegisterType
+ address: number
+ dataType: DataType
+}
+
+const REGISTER_DEFINITION_REGEXP = new RegExp('^([a-z]@)?(\\d+)(\\/[a-z0-9]*)?$')
+
+export const stringify = (r: ModbusRegister): string => {
+ return `${r.registerType}@${r.address}/${r.dataType}`
+}
+
+export const getRegisterLength = (r: ModbusRegister): number => {
+ switch (r.dataType) {
+ case 'int32':
+ case 'uint32':
+ return 4
+ case 'int16':
+ case 'uint16':
+ case 'float':
+ return 2
+ case 'boolean':
+ default:
+ return 1
+ }
+}
+
+export const parseRegisterDefinition = (definition: string): ModbusRegister => {
+ const result = REGISTER_DEFINITION_REGEXP.exec(definition)
+
+ if (result === null) {
+ throw new Error(`Unable to parse register definition "${definition}"`)
+ }
+
+ let [, registerType, , dataType] = result
+ const address = result[2]
+
+ // Parse register type
+ if (registerType === undefined) {
+ registerType = getDefaultRegisterType()
+ } else {
+ registerType = registerType.substring(0, registerType.length - 1)
+ }
+
+ if (!isValidRegisterType(registerType)) {
+ throw new Error(`Invalid register type specified: ${registerType}`)
+ }
+
+ // Parse data address
+ const parsedAddress = parseInt(address, 10)
+
+ // Parse data type
+ if (dataType === undefined) {
+ dataType = getDefaultDataType(registerType)
+ } else {
+ dataType = dataType.substring(1)
+ }
+
+ if (!isValidDataType(dataType)) {
+ throw new Error(`Invalid data type specified: ${dataType}`)
+ }
+
+ return {
+ registerType: registerType as RegisterType,
+ address: parsedAddress,
+ dataType,
+ }
+}
+
+const getDefaultRegisterType = (): RegisterType => {
+ return RegisterType.HOLDING_REGISTER
+}
+
+const getDefaultDataType = (registerType: RegisterType): DataType => {
+ if (registerType === RegisterType.INPUT_REGISTER || registerType === RegisterType.HOLDING_REGISTER) {
+ return 'int16'
+ } else {
+ return 'boolean'
+ }
+}
+
+const isValidRegisterType = (registerType: string): registerType is RegisterType => {
+ return Object.values(RegisterType).includes(registerType)
+}
+
+const isValidDataType = (dataType: string): dataType is DataType => {
+ return dataTypes.includes(dataType)
+}
diff --git a/src/sensor.ts b/src/sensor.ts
index d2d73b3..26bd27d 100644
--- a/src/sensor.ts
+++ b/src/sensor.ts
@@ -1,6 +1,7 @@
import { Circuit } from './circuit'
import { Characteristics } from './characteristics'
import { PowerSensorFilters } from './filter/filter'
+import { ModbusRegister } from './modbus/register'
export enum SensorType {
Iotawatt = 'iotawatt',
@@ -83,8 +84,7 @@ export interface ModbusSensorSettings {
address: string
port: number
unit: number
- register: number
- type: 'int16'
+ register: string | ModbusRegister
}
export interface ModbusSensor extends PowerSensor {
diff --git a/src/sensor/modbus.ts b/src/sensor/modbus.ts
index 809dc94..84f43ea 100644
--- a/src/sensor/modbus.ts
+++ b/src/sensor/modbus.ts
@@ -1,14 +1,13 @@
-import {
- emptySensorData,
- ModbusSensor,
- ModbusSensorSettings,
- PowerSensorData,
- PowerSensorPollFunction,
-} from '../sensor'
+import { emptySensorData, ModbusSensor, PowerSensorData, PowerSensorPollFunction } from '../sensor'
import { Circuit } from '../circuit'
-import { ReadRegisterResult } from 'modbus-serial/ModbusRTU'
+import { ReadCoilResult, ReadRegisterResult } from 'modbus-serial/ModbusRTU'
import { createLogger } from '../logger'
import { getClient, requestTimeout } from '../modbus/client'
+import { getRegisterLength, ModbusRegister, RegisterType, stringify } from '../modbus/register'
+import ModbusRTU from 'modbus-serial'
+
+export const DEFAULT_PORT = 502
+export const DEFAULT_UNIT = 1
const logger = createLogger('sensor.modbus')
@@ -36,13 +35,13 @@ export const getSensorData: PowerSensorPollFunction = async (
}
// Read the register and parse it accordingly
- logger.debug(`Reading holding register ${sensorSettings.register}`)
- const readRegisterResult = await client.readHoldingRegisters(sensorSettings.register, 1)
+ const register = sensorSettings.register as ModbusRegister
+ const readRegisterResult = await readRegisters(client, register)
return {
timestamp,
circuit,
- power: parseReadRegisterResult(readRegisterResult, sensorSettings),
+ power: parseReadRegisterResult(readRegisterResult, register),
}
} catch (e) {
logger.error(e)
@@ -51,8 +50,40 @@ export const getSensorData: PowerSensorPollFunction = async (
}
}
-export const parseReadRegisterResult = (result: ReadRegisterResult, sensorSettings: ModbusSensorSettings): number => {
- switch (sensorSettings.type) {
+const readRegisters = async (
+ client: ModbusRTU,
+ register: ModbusRegister,
+): Promise => {
+ logger.debug(`Reading register/coil ${stringify(register)}`)
+ const address = register.address
+ const length = getRegisterLength(register)
+
+ switch (register.registerType) {
+ case RegisterType.HOLDING_REGISTER:
+ return await client.readHoldingRegisters(address, length)
+ case RegisterType.INPUT_REGISTER:
+ return await client.readInputRegisters(address, length)
+ case RegisterType.COIL:
+ return await client.readCoils(address, length)
+ case RegisterType.DISCRETE_INPUT:
+ return await client.readDiscreteInputs(address, length)
+ }
+}
+
+const parseReadRegisterResult = (result: ReadRegisterResult | ReadCoilResult, register: ModbusRegister): number => {
+ switch (register.dataType) {
+ case 'float':
+ // Assume mixed-endian encoding is used
+ return result.buffer.swap16().readFloatLE()
+ case 'uint32':
+ return result.buffer.readUint32BE()
+ case 'int32':
+ return result.buffer.readInt32BE()
+ case 'uint16':
+ return result.buffer.readUint16BE()
+ case 'boolean':
+ // Convert to number
+ return (result as ReadCoilResult).data[0] ? 1 : 0
case 'int16':
default:
return result.buffer.readInt16BE()
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 32699d9..9abe4d6 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -1,5 +1,5 @@
import { Config, resolveAndValidateConfig } from '../src/config'
-import { SensorType, ShellySensor, ShellyType, UnmeteredSensor, VirtualSensor } from '../src/sensor'
+import { ModbusSensor, SensorType, ShellySensor, ShellyType, UnmeteredSensor, VirtualSensor } from '../src/sensor'
import { CircuitType } from '../src/circuit'
import {
createNestedUnmeteredConfig,
@@ -24,6 +24,18 @@ test('defaults are applied', () => {
},
},
},
+ {
+ name: 'Some other circuit',
+ sensor: {
+ type: SensorType.Modbus,
+ modbus: {
+ address: '127.0.0.1',
+ // port should be 502
+ // unit should be 1
+ register: 100,
+ },
+ },
+ },
],
} as unknown as Config)
@@ -32,8 +44,12 @@ test('defaults are applied', () => {
expect(config.publishers.length).toEqual(0)
expect(config.circuits[0].type).toEqual(CircuitType.Circuit)
expect(config.circuits[0].hidden).toEqual(false)
- const sensor = config.circuits[0].sensor as ShellySensor
- expect(sensor.shelly.type).toEqual(ShellyType.Gen1)
+ const shellySensor = config.circuits[0].sensor as ShellySensor
+ expect(shellySensor.shelly.type).toEqual(ShellyType.Gen1)
+
+ const modbusSensor = config.circuits[1].sensor as ModbusSensor
+ expect(modbusSensor.modbus.port).toEqual(502)
+ expect(modbusSensor.modbus.unit).toEqual(1)
})
test('polling interval cannot be set too low', () => {
diff --git a/tests/modbus/register.test.ts b/tests/modbus/register.test.ts
new file mode 100644
index 0000000..3b33604
--- /dev/null
+++ b/tests/modbus/register.test.ts
@@ -0,0 +1,75 @@
+import { ModbusRegister, parseRegisterDefinition, RegisterType } from '../../src/modbus/register'
+
+test('parse valid register definitions works', () => {
+ const definitions: { raw: string; parsed: ModbusRegister }[] = [
+ // Backward compatibility and sane default
+ {
+ 'raw': '866',
+ 'parsed': {
+ registerType: RegisterType.HOLDING_REGISTER,
+ address: 866,
+ dataType: 'int16',
+ },
+ },
+ {
+ 'raw': 'i@32000/float',
+ 'parsed': {
+ registerType: RegisterType.INPUT_REGISTER,
+ address: 32000,
+ dataType: 'float',
+ },
+ },
+ {
+ 'raw': 'h@32100/uint32',
+ 'parsed': {
+ registerType: RegisterType.HOLDING_REGISTER,
+ address: 32100,
+ dataType: 'uint32',
+ },
+ },
+ {
+ 'raw': 'h@32200',
+ 'parsed': {
+ registerType: RegisterType.HOLDING_REGISTER,
+ address: 32200,
+ dataType: 'int16',
+ },
+ },
+ {
+ 'raw': 'c@100/boolean',
+ 'parsed': {
+ registerType: RegisterType.COIL,
+ address: 100,
+ dataType: 'boolean',
+ },
+ },
+ {
+ 'raw': 'c@100',
+ 'parsed': {
+ registerType: RegisterType.COIL,
+ address: 100,
+ dataType: 'boolean',
+ },
+ },
+ {
+ 'raw': 'd@200',
+ 'parsed': {
+ registerType: RegisterType.DISCRETE_INPUT,
+ address: 200,
+ dataType: 'boolean',
+ },
+ },
+ ]
+
+ for (const definition of definitions) {
+ const { raw, parsed } = definition
+
+ expect(parseRegisterDefinition(raw)).toEqual(parsed)
+ }
+})
+
+test('parse invalid register definitions works', () => {
+ expect(() => parseRegisterDefinition('totally invalid')).toThrow('Unable to parse register definition')
+ expect(() => parseRegisterDefinition('a@32000/float')).toThrow('Invalid register type specified')
+ expect(() => parseRegisterDefinition('h@32000/foo')).toThrow('Invalid data type specified')
+})