Skip to content

Commit

Permalink
Merge pull request #69 from Jalle19/modbus-v15
Browse files Browse the repository at this point in the history
Expanded Modbus sensor support
  • Loading branch information
Jalle19 authored Aug 29, 2024
2 parents 33a2334 + 82c4768 commit faf03b2
Show file tree
Hide file tree
Showing 10 changed files with 268 additions and 27 deletions.
1 change: 0 additions & 1 deletion .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions examples/config.sample.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,6 +16,7 @@ import {
} from './sensor/dummy'
import {
CharacteristicsSensorType,
ModbusSensor,
SensorType,
ShellySensor,
ShellyType,
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
98 changes: 98 additions & 0 deletions src/modbus/register.ts
Original file line number Diff line number Diff line change
@@ -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<string>(RegisterType).includes(registerType)
}

const isValidDataType = (dataType: string): dataType is DataType => {
return dataTypes.includes(dataType)
}
4 changes: 2 additions & 2 deletions src/sensor.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 44 additions & 13 deletions src/sensor/modbus.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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)
Expand All @@ -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<ReadRegisterResult | ReadCoilResult> => {
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()
Expand Down
22 changes: 19 additions & 3 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)

Expand All @@ -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', () => {
Expand Down
Loading

0 comments on commit faf03b2

Please sign in to comment.