Skip to content

Commit

Permalink
Merge pull request #87 from Jalle19/modbus-tcp
Browse files Browse the repository at this point in the history
Add Modbus TCP support
  • Loading branch information
Jalle19 authored Sep 7, 2023
2 parents b041c35 + d61265f commit aa7f99f
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 15 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change log

## future release

* Add Modbus TCP support (https://github.com/Jalle19/eda-modbus-bridge/issues/86)

## 2.5.0

* Bump `serialport` to v11.0.1, should fix runtime crash on Raspberry Pi 4 (https://github.com/Jalle19/home-assistant-addon-repository/issues/30)
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
[![CodeQL](https://github.com/Jalle19/eda-modbus-bridge/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/Jalle19/eda-modbus-bridge/actions/workflows/codeql-analysis.yml)
[![Run tests](https://github.com/Jalle19/eda-modbus-bridge/actions/workflows/test.yml/badge.svg)](https://github.com/Jalle19/eda-modbus-bridge/actions/workflows/test.yml)

An HTTP/MQTT bridge for Enervent ventilation units with EDA (and its successor, MD) automation (e.g. Pingvin and Pandion). It provides a REST-ful
HTTP interface for interacting with the ventilation unit (reading temperatures and changing certain settings), as well
as an MQTT client which can publish readings/settings regularly and be used to control the ventilation unit.
An HTTP/MQTT bridge for Enervent ventilation units with EDA or MD automation (e.g. Pingvin, Pelican and Pandion). It
provides a REST-ful HTTP interface for interacting with the ventilation unit (reading temperatures and changing certain
settings), as well as an MQTT client which can publish readings/settings regularly and be used to control the
ventilation unit.

Communication happens over RS-485 (Modbus RTU) by connecting a serial device to the "Freeway" port on the ventilation
unit's computer board.
unit's computer board, or alternatively using Modbus TCP for newer units that can be connected to the local network.

The REST endpoints for enabling/disabling the various modes are designed to be consumed by
https://www.home-assistant.io/integrations/switch.rest/ with minimal effort. See examples in the `docs/` directory.
Expand Down Expand Up @@ -42,9 +43,10 @@ https://www.home-assistant.io/integrations/switch.rest/ with minimal effort. See
## Requirements

* Node.js 14.x or newer
* An Enervent ventilation unit with EDA or MD automation (Pingvin, Pandion and LTR-3 confirmed working)
* An Enervent ventilation unit with EDA or MD automation (Pingvin, Pandion, Pelican and LTR-3 confirmed working)
* An RS-485 device (e.g. `/dev/ttyUSB0`) connected to the Enervent unit's Freeway port (see
[docs/CONNECTION.md](./docs/CONNECTION.md) for details on how to connect to the unit)
[docs/CONNECTION.md](./docs/CONNECTION.md) for details on how to connect to the unit). Newer units that can be
connected directly to the local network don't need this.

## Installation

Expand Down
27 changes: 27 additions & 0 deletions app/modbus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export let AVAILABLE_ALARMS = {
21: { name: 'ExtractFanPressureError', description: 'Waste fan pressure' },
}

export const MODBUS_DEVICE_TYPE = {
'RTU': 'rtu',
'TCP': 'tcp',
}

const mutex = new Mutex()
const logger = createLogger('modbus')

Expand Down Expand Up @@ -465,6 +470,28 @@ export const parseStateBitField = (state) => {
}
}

export const validateDevice = (device) => {
return device.startsWith('/') || device.startsWith('tcp://')
}

export const parseDevice = (device) => {
if (device.startsWith('/')) {
// Serial device
return {
type: MODBUS_DEVICE_TYPE.RTU,
path: device,
}
} else {
// TCP URL
const deviceUrl = new URL(device)
return {
type: MODBUS_DEVICE_TYPE.TCP,
hostname: deviceUrl.hostname,
port: parseInt(deviceUrl.port, 10),
}
}
}

const tryReadCoils = async (modbusClient, dataAddress, length) => {
try {
logger.debug(`Reading coil address ${dataAddress}, length ${length}`)
Expand Down
11 changes: 11 additions & 0 deletions docs/CONNECTION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Connecting to an Enervent unit

There are two ways to communicate with your ventilation unit:

* Modbus RTU
* Modbus TCP

If your ventilation unit has an RJ45 port you can use Modbus TCP. Connect the unit to your local network and take note
of what IP address it has.

If your ventilation unit does not have an RJ45 port (very common), read on. The rest of this document focuses on how
to communicate using Modbus RTU.

These instructions are based on:

* https://doc.enervent.com/op/op.ViewOnline.php?documentid=999&version=1 (Freeway WEB-väyläsovitin - Asennus- ja käyttöohjeet)
Expand Down
33 changes: 24 additions & 9 deletions eda-modbus-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import {
} from './app/mqtt.mjs'
import { configureMqttDiscovery } from './app/homeassistant.mjs'
import { createLogger, setLogLevel } from './app/logger.mjs'
import { MODBUS_DEVICE_TYPE, parseDevice, validateDevice } from './app/modbus.mjs'

const MQTT_INITIAL_RECONNECT_RETRY_INTERVAL_SECONDS = 5

const argv = yargs(process.argv.slice(2))
.usage('node $0 [options]')
.options({
'device': {
description: 'The serial device to use, e.g. /dev/ttyUSB0',
description:
'The Modbus device to use, e.g. /dev/ttyUSB0 for Modbus RTU or tcp://192.168.1.40:502 for Modbus TCP',
demand: true,
alias: 'd',
},
Expand Down Expand Up @@ -86,17 +88,30 @@ const argv = yargs(process.argv.slice(2))

const httpLogger = createLogger('http')

// Create Modbus client
logger.info(`Opening serial connection to ${argv.device}, slave ID ${argv.modbusSlave}`)
// Create Modbus client. Abort if a malformed device is specified.
if (!validateDevice(argv.device)) {
logger.error(`Malformed Modbus device ${argv.device} specified, exiting`)
process.exit(1)
}
logger.info(`Opening Modbus connection to ${argv.device}, slave ID ${argv.modbusSlave}`)
const modbusDevice = parseDevice(argv.device)
const modbusClient = new ModbusRTU()
modbusClient.setID(argv.modbusSlave)
modbusClient.setTimeout(5000) // 5 seconds
await modbusClient.connectRTUBuffered(argv.device, {
baudRate: 19200,
dataBits: 8,
parity: 'none',
stopBits: 1,
})

// Use buffered RTU or TCP depending on device type
if (modbusDevice.type === MODBUS_DEVICE_TYPE.RTU) {
await modbusClient.connectRTUBuffered(modbusDevice.path, {
baudRate: 19200,
dataBits: 8,
parity: 'none',
stopBits: 1,
})
} else if (modbusDevice.type === MODBUS_DEVICE_TYPE.TCP) {
await modbusClient.connectTCP(modbusDevice.hostname, {
port: modbusDevice.port,
})
}

// Optionally create HTTP server
if (argv.http) {
Expand Down
27 changes: 27 additions & 0 deletions tests/modbus.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
getDeviceFamilyName,
getAutomationAndHeatingTypeName,
parseStateBitField,
validateDevice,
parseDevice,
MODBUS_DEVICE_TYPE,
} from '../app/modbus.mjs'

test('parse temperature', () => {
Expand Down Expand Up @@ -162,3 +165,27 @@ test('parse state bitfield', () => {
'defrosting': false,
})
})

test('validateDevice', () => {
expect(validateDevice('/dev/ttyUSB0')).toEqual(true)
expect(validateDevice('dev/ttyUSB0')).toEqual(false)
expect(validateDevice('tcp://192.168.1.40:502')).toEqual(true)
expect(validateDevice('192.168.1.40:502')).toEqual(false)
})

test('parseDevice', () => {
expect(parseDevice('/dev/ttyUSB0')).toEqual({
type: MODBUS_DEVICE_TYPE.RTU,
path: '/dev/ttyUSB0',
})
expect(parseDevice('tcp://localhost:502')).toEqual({
type: MODBUS_DEVICE_TYPE.TCP,
hostname: 'localhost',
port: 502,
})
expect(parseDevice('tcp://127.0.0.1:502')).toEqual({
type: MODBUS_DEVICE_TYPE.TCP,
hostname: '127.0.0.1',
port: 502,
})
})

0 comments on commit aa7f99f

Please sign in to comment.