# Modbus RTU API reference

[Česká verze](modbus-api.cs.md)

HELLOS-UNI can run as a Modbus RTU server on the same half-duplex RS-485
interface used by HBUS. The implementation is intentionally isolated from the
HBUS transport. Selecting a protocol takes effect after restart.

This document applies to HELLOS-UNI firmware 4.0 and register-map version 1.

## Serial and server behavior

- 8 data bits, no parity, 1 stop bit (8N1).
- The configured HBUS baud rate is also used by Modbus RTU.
- Server address range: 1 through 247.
- Supported functions: `03`, `04`, `06` and `16`.
- Register words and multi-word values are big-endian. Modbus CRC bytes are
  sent low byte first.
- A 32-bit value occupies two consecutive registers, high word first.
- At 19200 baud and below, frame separation is 3.5 character times. Above
  19200 baud it is fixed at 1.75 ms.
- The module never sends unsolicited Modbus frames. HBUS event data is stored
  in event registers until a client reads it.
- Broadcast address zero accepts ordinary output and PWM writes without a
  response. It cannot execute the recovery service command.

The addresses below are zero-based Modbus PDU addresses. Software that shows
one-based `3xxxx` or `4xxxx` notation may display a value one greater than the
address in this document.

## Value conventions

- `0xffff` means unavailable or an unused slot.
- `0x8000` means an unavailable signed measurement.
- Signed values use 16-bit two's complement.
- Temperature and humidity values are multiplied by 10.
- ADC values use tenths of a percent from 0 through 1000.
- PWM duty uses integer percent from 0 through 100.

## Input registers - function 04

### System registers

| Address | Value |
| ---: | --- |
| 0 | Register-map version, currently `1` |
| 1 | Status flags: bit 0 debug, bit 1 filesystem update, bit 2 event overflow, bit 3 sensor-slot overflow |
| 2-3 | Uptime in seconds, unsigned 32-bit |
| 4-5 | Hardware-derived device ID, unsigned 32-bit |
| 6-7 | UART baud rate, unsigned 32-bit |
| 8 | Modbus address |
| 9 | Configured mode: 0 HBUS, 1 Modbus RTU |
| 10 | Active mode: 0 HBUS, 1 Modbus RTU |
| 11 | Used DS18x20 slots, maximum 32 |
| 12 | Used SHT3x slots, maximum 6 |
| 13 | Used SCD4x slots, maximum 6 |
| 14-16 | Port mode code for A, B and C |
| 17-18 | CRC32 of the complete software version string |
| 19-20 | CRC32 of the hardware version string |
| 21 | Sensor-slot overflow flags: DS bit 0, SHT bit 1, SCD bit 2 |
| 22-31 | Reserved, reads as `0xffff` |

Port mode codes are stable within map version 1:

| Code | Mode | Code | Mode |
| ---: | --- | ---: | --- |
| 0 | sensor-autodetect | 8 | output_safe |
| 1 | button_simple | 9 | pwm_50hz |
| 2 | button_double | 10 | pwm_1khz |
| 3 | input | 11 | pwm_25khz |
| 4 | input_safe | 12 | adc_raw_passive |
| 5 | impulse_counter | 13 | adc_raw_active |
| 6 | impulse_counter_safe | 14 | adc_pullup_passive |
| 7 | output | 15 | adc_pullup_active |
| 16 | encoder | 17 | encoder_reverse |

### Port-state registers

Every 12-register block uses channels `A0` through `A3`, `B0` through `B3`,
then `C0` through `C3`.

| Address | Value |
| ---: | --- |
| 100-111 | Logical input state, 0 or 1 |
| 120-143 | Impulse counters, 12 unsigned 32-bit values |
| 150-161 | ADC value in tenths of a percent |
| 170-181 | Logical output state, 0 or 1 |
| 190-201 | PWM duty in percent |

### Compact digital-input polling

The compact block is intended for installations with many modules. A client
reads registers 32 through 46 in one request and compares the press counters
with its previous sample. This does not lose a short press between polling
cycles and keeps the client implementation to one request per module.

Bits and counter positions use channels `A0` through `A3`, `B0` through `B3`,
then `C0` through `C3`.

| Address | Value |
| ---: | --- |
| 32 | Current logical input-state bitmap, bits 0 through 11 |
| 33 | Valid input-state bitmap for `input` and `input_safe` ports |
| 34 | Valid press-counter bitmap for button and input ports |
| 35-46 | Unsigned 16-bit press counter per channel |

The counters wrap from 65535 to zero and reset after module restart. A
logical-input transition to active and a `button_simple` or
`button_double` press increment the corresponding counter. Releases do not.
The first values read after connecting establish the client baseline and must
not be interpreted as new presses.

### Communication diagnostics

Diagnostic counters are unsigned 32-bit values stored high word first. They
wrap naturally and reset after module restart.

| Address | Value |
| ---: | --- |
| 50-51 | Valid requests received for this module |
| 52-53 | Invalid or overflowing RTU frames received |
| 54-55 | Valid RTU frames addressed to another module |
| 56-57 | Responses successfully handed to the UART |
| 58-59 | Response transmission failures |

### Event registers

Event registers preserve the latest event per physical channel. A client
detects a new event by observing a change in its sequence register. Sequence
numbers wrap from 65535 to zero.

| Address | Value |
| ---: | --- |
| 220-231 | Event sequence per channel |
| 240-251 | Last event type per channel |
| 260-271 | Last event value per channel, signed 16-bit |
| 280-303 | Last event timestamp in milliseconds, 12 unsigned 32-bit values |
| 320-343 | Accumulated encoder position, 12 signed 32-bit values |

Event type codes are 1 button press, 2 button release, 3 logical input, 4
encoder delta and 5 ADC change. Button events store value 1. ADC event values
use tenths of a percent.

### Sensor slots

DS18x20 slots are sorted by their 32-bit HBUS sensor identifier. SHT3x and
SCD4x slots are sorted by their HBUS index. A slot therefore remains stable as
long as the set of detected devices and their identifiers remains unchanged.

| Address | Slot layout |
| ---: | --- |
| 400-527 | 32 DS18x20 slots, four registers each: ID high, ID low, signed temperature x10, flags |
| 600-623 | 6 SHT3x slots, four registers each: HBUS index, signed temperature x10, humidity x10, flags |
| 640-669 | 6 SCD4x slots, five registers each: HBUS index, CO2 ppm, signed temperature x10, humidity x10, flags |

Flags currently contain 1 for a populated slot. Measurements include the
configured compensation table, matching an HBUS read-all response.

Undefined addresses from 0 through 669 read as `0xffff`. Reading above 669
returns Modbus exception 02.

## Holding registers - functions 03, 06 and 16

| Address | Access | Value |
| ---: | --- | --- |
| 1000-1011 | read/write | Logical outputs A0-C3, 0 or 1 |
| 1020-1031 | read/write | PWM duty A0-C3, integer 0 through 100 |
| 1999 | read/write | Recovery service command |

Registers for channels that are not configured for the corresponding output
mode read as `0xffff` and reject writes. Function 16 validates the complete
request before applying it. One request cannot cross from the output block
into the PWM block.

Writing hexadecimal `0x4842` to register 1999 with function 06 stores HBUS as
the configured mode, returns the normal write response and restarts the
module. This command is deliberately rejected at broadcast address zero. The
register otherwise reads as zero.

## Configuration and recovery

Configure bus mode and Modbus address through HBUS before switching:

```json
{"device":{"set_bus_mode":{"mode":"modbus","modbus_address":17}}}
```

The stored mode is used on the next restart. Enabling the debug DIP switch at
boot forces HBUS for that boot only and keeps the stored baud rate. This allows
the settings to be corrected without erasing the module. Factory reset removes
the bus configuration and baud-rate file, restoring HBUS at 19200 baud.

## Practical Node-RED examples

The examples below use a module with Modbus address 1. Change `unitid` to the
configured address of the target module.

### Required serial settings

Use these settings in the `node-red-contrib-modbus` client:

- serial connection
- the same baud rate as the module
- 8 data bits, no parity and 1 stop bit
- serial type `RTU-BUFFERD`

The spelling `RTU-BUFFERD` is used by the Node-RED package. It is not the same
option as command queue buffering.

The non-buffered `RTU` serial driver used during testing only handled reads up
to 13 registers reliably. A function-04 response for 13 registers is 31 bytes,
while a response for 14 registers is 33 bytes. The operating-system serial
layer can split the longer response into multiple chunks and the non-buffered
driver may treat the first chunk as the complete frame. `RTU-BUFFERD` collects
the chunks until the expected Modbus response length has been received. Use it
for every example in this section.

The following helper accepts the two common output shapes produced by the
Modbus Flex Getter node:

```js
function registerData(msg) {
    if (Array.isArray(msg.payload)) {
        return msg.payload;
    }
    if (msg.payload && Array.isArray(msg.payload.data)) {
        return msg.payload.data;
    }
    throw new Error("Modbus response does not contain a register array");
}
```

### Read the complete module configuration

Use a Function node connected to a Modbus Flex Getter:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 0,
    quantity: 22
};

return msg;
```

Decode the response with another Function node:

```js
function registerData(msg) {
    if (Array.isArray(msg.payload)) {
        return msg.payload;
    }
    if (msg.payload && Array.isArray(msg.payload.data)) {
        return msg.payload.data;
    }
    throw new Error("Modbus response does not contain a register array");
}

function u32(high, low) {
    return ((high << 16) | low) >>> 0;
}

function hex32(value) {
    return value.toString(16).padStart(8, "0");
}

const portModes = {
    0: "sensor-autodetect",
    1: "button_simple",
    2: "button_double",
    3: "input",
    4: "input_safe",
    5: "impulse_counter",
    6: "impulse_counter_safe",
    7: "output",
    8: "output_safe",
    9: "pwm_50hz",
    10: "pwm_1khz",
    11: "pwm_25khz",
    12: "adc_raw_passive",
    13: "adc_raw_active",
    14: "adc_pullup_passive",
    15: "adc_pullup_active",
    16: "encoder",
    17: "encoder_reverse"
};

const r = registerData(msg);
const flags = r[1];
const sensorOverflow = r[21];

msg.payload = {
    firmware_api: "4.0",
    register_map_version: r[0],
    flags: {
        raw: flags,
        debug: Boolean(flags & 1),
        filesystem_update: Boolean(flags & 2),
        event_overflow: Boolean(flags & 4),
        sensor_slot_overflow: Boolean(flags & 8)
    },
    uptime: u32(r[2], r[3]),
    device_id: hex32(u32(r[4], r[5])),
    baudrate: u32(r[6], r[7]),
    modbus_address: r[8],
    configured_mode: r[9] === 1 ? "modbus" : "hbus",
    active_mode: r[10] === 1 ? "modbus" : "hbus",
    sensors: {
        ds18x20: r[11],
        sht3x: r[12],
        scd4x: r[13]
    },
    ports: {
        A: portModes[r[14]] || `unknown-${r[14]}`,
        B: portModes[r[15]] || `unknown-${r[15]}`,
        C: portModes[r[16]] || `unknown-${r[16]}`
    },
    software_crc32: hex32(u32(r[17], r[18])),
    hardware_crc32: hex32(u32(r[19], r[20])),
    sensor_overflow: {
        raw: sensorOverflow,
        ds18x20: Boolean(sensorOverflow & 1),
        sht3x: Boolean(sensorOverflow & 2),
        scd4x: Boolean(sensorOverflow & 4)
    }
};

return msg;
```

This block contains the complete configuration exposed through Modbus. Alias,
compensation-table editing and other HBUS-specific configuration operations are
not exposed as Modbus registers in firmware 4.0.

### Discover connected sensors

The complete configuration read above already returns the number of used
sensor slots in registers 11 through 13. To read only these counts:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 11,
    quantity: 3
};

return msg;
```

The returned array contains:

```text
[DS18x20 count, SHT3x count, SCD4x count]
```

Do not request a sensor block when its count is zero. For a non-zero count,
read exactly the number of populated slots:

| Sensor | Start address | Registers per sensor | Read quantity |
| --- | ---: | ---: | ---: |
| DS18x20 | 400 | 4 | `count * 4` |
| SHT3x | 600 | 4 | `count * 4` |
| SCD4x | 640 | 5 | `count * 5` |

### Read DS18x20 sensors

This request reads one DS18x20 slot:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 400,
    quantity: 4
};

return msg;
```

Decode one or more slots:

```js
function registerData(msg) {
    return Array.isArray(msg.payload) ? msg.payload : msg.payload.data;
}

function u32(high, low) {
    return ((high << 16) | low) >>> 0;
}

function signed16(value) {
    return value & 0x8000 ? value - 0x10000 : value;
}

const r = registerData(msg);
const sensors = [];

for (let offset = 0; offset + 3 < r.length; offset += 4) {
    if (r[offset] === 0xffff && r[offset + 1] === 0xffff) {
        continue;
    }
    sensors.push({
        id: u32(r[offset], r[offset + 1]).toString(16).padStart(8, "0"),
        temperature: signed16(r[offset + 2]) / 10,
        valid: Boolean(r[offset + 3] & 1)
    });
}

msg.payload = sensors;
return msg;
```

### Read SHT3x sensors

For one SHT3x slot, read address 600 with quantity 4. Each slot contains the
HBUS index, temperature multiplied by 10, humidity multiplied by 10 and flags:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 600,
    quantity: 4
};

return msg;
```

```js
function registerData(msg) {
    return Array.isArray(msg.payload) ? msg.payload : msg.payload.data;
}

function signed16(value) {
    return value & 0x8000 ? value - 0x10000 : value;
}

const r = registerData(msg);
const sensors = [];

for (let offset = 0; offset + 3 < r.length; offset += 4) {
    if (r[offset] === 0xffff) {
        continue;
    }
    sensors.push({
        index: r[offset],
        temperature: signed16(r[offset + 1]) / 10,
        humidity: r[offset + 2] / 10,
        valid: Boolean(r[offset + 3] & 1)
    });
}

msg.payload = sensors;
return msg;
```

### Read SCD4x sensors

For one SCD4x slot:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 640,
    quantity: 5
};

return msg;
```

Decode one or more slots:

```js
function registerData(msg) {
    return Array.isArray(msg.payload) ? msg.payload : msg.payload.data;
}

function signed16(value) {
    return value & 0x8000 ? value - 0x10000 : value;
}

const r = registerData(msg);
const sensors = [];

for (let offset = 0; offset + 4 < r.length; offset += 5) {
    if (r[offset] === 0xffff) {
        continue;
    }
    sensors.push({
        index: r[offset],
        co2: r[offset + 1],
        temperature: signed16(r[offset + 2]) / 10,
        humidity: r[offset + 3] / 10,
        valid: Boolean(r[offset + 4] & 1)
    });
}

msg.payload = sensors;
return msg;
```

All sensor measurements include the configured compensation values.

### Read digital inputs and press counters

Read registers 32 through 46 to obtain all 12 physical channels in one
request:

```js
msg.payload = {
    value: 0,
    fc: 4,
    unitid: 1,
    address: 32,
    quantity: 15
};

return msg;
```

If only port A is configured as `input` or `input_safe`, quantity 7 is enough.
It reads the three bitmaps and counters A0 through A3.

```js
function registerData(msg) {
    return Array.isArray(msg.payload) ? msg.payload : msg.payload.data;
}

const r = registerData(msg);
const stateBitmap = r[0];
const inputValidBitmap = r[1];
const pressValidBitmap = r[2];
const channels = [];

for (let channel = 0; channel < r.length - 3; channel++) {
    const mask = 1 << channel;
    channels.push({
        index: channel,
        port: ["A", "B", "C"][Math.floor(channel / 4)],
        pin: channel % 4,
        input_valid: Boolean(inputValidBitmap & mask),
        active: Boolean(stateBitmap & mask),
        press_counter_valid: Boolean(pressValidBitmap & mask),
        press_counter: r[3 + channel]
    });
}

msg.payload = {
    state_bitmap: stateBitmap,
    input_valid_bitmap: inputValidBitmap,
    press_valid_bitmap: pressValidBitmap,
    channels: channels
};

return msg;
```

For reliable button handling, compare each press counter with its value from
the previous polling cycle. The unsigned difference is:

```js
const delta = (currentCounter - previousCounter) & 0xffff;
```

The first sample establishes the baseline and must not generate press events.
This method preserves short presses that start and finish between two polling
cycles.

### Write and read logical outputs

The channel order is A0-A3, B0-B3 and C0-C3. For example, B0 is holding
register 1004.

Set B0 to logical 1 with a Modbus Flex Write node:

```js
msg.payload = {
    value: 1,
    fc: 6,
    unitid: 1,
    address: 1004,
    quantity: 1
};

return msg;
```

Set all four pins of port B in one function-16 request:

```js
msg.payload = {
    value: [1, 0, 1, 0],
    fc: 16,
    unitid: 1,
    address: 1004,
    quantity: 4
};

return msg;
```

Read the four output states back with a Modbus Flex Getter:

```js
msg.payload = {
    value: 0,
    fc: 3,
    unitid: 1,
    address: 1004,
    quantity: 4
};

return msg;
```

The target port must be configured as `output` or `output_safe`. Writes to a
channel in another mode are rejected.

### Write and read PWM outputs

PWM registers use integer duty cycle from 0 through 100. For example, B0 is
holding register 1024.

Set B0 to 50 percent:

```js
msg.payload = {
    value: 50,
    fc: 6,
    unitid: 1,
    address: 1024,
    quantity: 1
};

return msg;
```

Set all four PWM channels on port B:

```js
msg.payload = {
    value: [0, 25, 50, 100],
    fc: 16,
    unitid: 1,
    address: 1024,
    quantity: 4
};

return msg;
```

Read the four duty-cycle values back with a Modbus Flex Getter:

```js
msg.payload = {
    value: 0,
    fc: 3,
    unitid: 1,
    address: 1024,
    quantity: 4
};

return msg;
```

The target port must be configured as `pwm_50hz`, `pwm_1khz` or
`pwm_25khz`. Values above 100 and writes to channels in another mode are
rejected.

### Return a module to HBUS mode

Use a Modbus Flex Write node to write the recovery magic value `0x4842`
(`18498` decimal) to holding register 1999:

```js
msg.payload = {
    value: 18498,
    fc: 6,
    unitid: 1,
    address: 1999,
    quantity: 1
};

return msg;
```

The module first returns the normal function-06 response, stores HBUS as the
configured mode and then restarts. The configured baud rate is preserved.
Address zero broadcast is deliberately rejected for this operation.
