This package contains nodes for controlling vallox devices via the serial RS485 API. You must connect the RS485 bus and read the data using e.g. an RS485 to serial converter. Then use a serial node to read the data and feed it to the node.
Node-RED 3.1.0 or newer on Node.js 20 or newer. The nodes themselves use only long-standing runtime APIs, so the floor is set by Node.js 20: Node-RED 3.1.0 is the first release that supports it. The test suite runs against that floor.
You also need a byte stream to and from the unit — typically node-red-node-serialport on an RS485 adapter, or an MQTT bridge.
None at runtime — the nodes ship no third-party code and the RS485 protocol is implemented in this package. See Requirements above for the companion node that provides the byte stream.
This package is not developed nor officially supported by the company Vallox. It is for demonstrating how to communicate to the devices using node-red.
If you want to support this free project. Any help is welcome. You can donate by clicking one of the following links:
Changes can be followed here
Wire an RS485 transceiver (e.g. an RS485-to-USB adapter) to your Vallox bus and read it with a serial in node, or bridge the stream over MQTT. The serial port settings are 9600 baud, 8 data bits, no parity, 1 stop bit (8N1).
A typical receive-and-send pipeline:
serial in / mqtt in --> valloxrx --> vallox --> valloxtx --> serial out / mqtt out
^ ^
| |
request msg (only when sending)
- valloxrx decodes the raw byte stream into structured frames.
- vallox keeps the latest value of each variable seen for a configured device address (panel or master) and turns request objects into outgoing frames.
- valloxtx serialises an outgoing frame to the 6-byte buffer that goes on the wire.
Decodes Vallox RS485 telegrams (6-byte frames) from a raw byte stream. Incoming bytes are buffered until a complete frame is available; misaligned leading bytes are dropped at the next start byte (0x01).
- Input
msg.payload— raw bytes from the serial port or MQTT: aBuffer, aUint8Array, an array of bytes, or the JSON text"[1,17,32,…]"that an MQTT hop makes of an array payload. Anything else is reported on output 2. Chunks need not align with frames, and the shapes may be mixed between messages. - Output 1 — decoded frame object with
domain,sender,receiver,command,arg,checksum,variable(human name, e.g.FanSpeed),request("GET"or"SET") and — forSETframes —value(already converted, e.g. fan speed 1-8 or temperature in °C). - Output 2 — error string (bad checksum, wrong frame length, …).
See examples/valloxrx.json for a minimal RX-only flow.
Diagnostics — two optional settings, both off by default:
- Diagnostics — logs every received chunk and every framing decision as hex, and puts running counters in the node status: bytes received, frames decoded out of the number the byte count allows, bytes dropped, checksum failures. A healthy stream is one frame per six bytes with no drops.
- Capture file — appends every received byte to a file, exactly as it arrived.
Then analyse the capture offline:
node tools/analyse-capture.js C:\temp\vallox.bin
It walks the capture, reports every point where the stream stops being a clean run of 6-byte frames, and says which kind of fault it is: a lost byte (the stream realigns one byte early), an extra byte (realigns one byte late), or a corrupted byte (alignment intact, one frame unreadable). Lost and extra bytes point at the transport — gateway chunking, MQTT queueing; corrupted bytes with the length intact point at the RS485 line itself, such as collisions or missing termination.
Encodes a telegram object into a 6-byte frame, ready for the bus. Normally fed by the vallox node's second output.
- Input
msg.payload—{ domain, sender, receiver, command, arg }. The checksum byte is computed and appended automatically. SetrepeatChecksum: truefor a write telegram and the checksum is sent twice, per Annex B. - Output 1 — the frame, either as
[domain, sender, receiver, command, arg, checksum]or as aBuffer. - Output 2 — error string.
Configuration
-
Output —
Array of bytes(default, and what this node has always emitted) orBuffer.A
serial outnode writes either form as bytes. Anything that serialises the payload does not:mqtt outJSON-encodes an array into the text[1,33,17,…], so the far end writes those twenty characters to the port instead of the six-byte telegram, and the unit ignores it. Choose Buffer whenever the frame leaves Node-RED as a message payload.
See examples/valloxtx.json for a minimal TX-only flow.
Holds the in-memory state of one Vallox device (panel or master) and generates outgoing requests.
Configuration
-
Receiver — RS485 address of the device this node represents:
- Panel 1-7:
33-39(0x21-0x27) - LON:
40(0x28) - Master 1:
17(0x11)
This address is also the sender address of every telegram the node emits, so it must not be one your hardware already uses. See Bus addressing below.
- Panel 1-7:
-
Send msg on new data — when checked, output 1 fires every time a matching
SETframe updates the state. -
Write like a panel — a
SETproduces the three telegrams a real control unit sends (mainboard with the checksum repeated, then all panels, then all mainboards). Unticking sends only the first; the master propagates the change itself.
Inputs (one of three shapes on msg.payload):
- A decoded frame from
valloxrx(object with areceiverfield). It updates the state when it comes from a mainboard (10H-1FH), whoever it is addressed to, or when it is addressed to this node or its address group. A register value is the same whichever panel asked for it, so the node picks up everything the physical panels poll for without polling itself; frames between two other modules are ignored. - A request object
{ request: "GET" | "SET", variable: "<name>", value: <value> }— produces an outgoing telegram on output 2. ASETon a readonly variable goes to output 3 as an error. - Anything else (or empty payload) — emits the current state snapshot on output 1.
Outputs
- Current state object — every variable seen so far, keyed by its human-readable name.
- Outgoing telegram object — feed into
valloxtx. - Error string — e.g. an attempt to
SETa readonly variable.
See examples/vallox.json for a complete RX + state + TX + MQTT flow.
The protocol assigns each module an address: mainboards 11H-1FH (1-15), panels and remote
controls 21H-2FH (1-15). 10H addresses all mainboards at once and 20H all panels. The
vallox node's Receiver setting is both the address whose traffic it adopts and the address it
sends from — it impersonates a panel.
The original Vallox control unit ships as Panel 1 (21H / 33), and each additional panel takes
the next address: 22H, 23H, and so on. Those are exactly the values at the top of the Receiver
dropdown, so the default choice is usually the wrong one.
Two modules answering to one address is not a configuration error the bus detects. Both consume the master's replies to that address, both may transmit as it, and because a write is broadcast to all panels and all mainboards, a duplicate address propagates confusion rather than containing it.
Pick an address no physical device uses — the far end of the range (27H / 39, Panel 7) is a
reasonable default for a single Node-RED instance. To see which addresses are taken, tick
Diagnostics on the valloxrx node, capture a minute of traffic and look at the senders:
node tools/analyse-capture.js C:\temp\vallox.bin
The capture also tells you what to expect: a two-panel system typically shows panel 1, panel 2
and mainboard 1 talking, and nothing else.
The bus is a single pair shared by up to 32 modules, and nothing arbitrates access — a module transmits when it has something to say. Two modules starting at once corrupt each other's bytes, and the protocol's only recovery is the requester's own retry: it waits 10 ms for a reply, repeats the request up to ten times, and then puts itself into a fault state. Corrupted frames therefore cost more than the frame; they cost bus time that makes further collisions likelier.
Every panel already polls the master continuously, so the bus is busy before you add anything. In a measured capture the damage rate roughly doubled — one bad frame per 317 bytes against one per 760 — while a panel was writing setpoints, purely because more modules were transmitting.
Practical consequences:
- Add as few transmitters as you can. One Node-RED node per system, not one per dashboard.
- Poll sparingly. One
GETevery few seconds covers a rotation of registers; the panels' own polling fills in the rest. A tight loop ofGETs is the easiest way to make a quiet bus noisy. - Prefer the broadcasts where they exist.
Humidity, both CO2 bytes and the four temperatures arrive every 12 seconds unasked — polling them adds traffic for nothing. - Expect occasional checksum errors and let the retry work. A frame lost to a collision reappears on the next poll; treat a persistent rate as a wiring problem (termination, biasing, stub length), not something to fix by polling harder.
To change the fan speed, send a request object to the vallox node and wire its output 2 into a valloxtx node. The function node that builds the request:
msg.payload = {
request: 'SET',
variable: 'FanSpeed',
value: msg.payload, // 1 .. 8
};
return msg;The same shape is used for every writable variable — see the Command reference below for the full list. Temperature setpoints (HeatingSetPoint, PreHeatingSetPoint, InputFanStop, HRCBypass) take a value in °C; the package converts to the NTC sensor byte automatically.
Reading the current state is even simpler: send any non-empty message (or an empty inject) to the vallox node and it emits the latest cached state on output 1.
The full flow with fan-speed injection buttons, an HTTP state endpoint and an MQTT bridge is in examples/vallox.json.
Import these from the Node-RED editor menu (Import → Examples → node-red-contrib-vallox).
| Flow | What it shows |
|---|---|
vallox-dashboard.json |
A node-red-dashboard page: temperatures, heat-recovery efficiency, fan speed, status, warnings and every setpoint, with a poll rotation |
vallox-commands.json |
One inject per writable variable — every command this package can send, with realistic values |
vallox-monitor.json |
Decoding the bus and caching every variable, with replayable sample frames and a corrupt one |
vallox.json |
Complete RX + state + TX flow with an MQTT bridge |
valloxrx.json |
Minimal receive-only flow |
valloxtx.json |
Minimal send-only flow |
serialmqtt.json |
Bridging the serial port to MQTT, for running the decoder on another host |
The two generated flows need no hardware: their inject nodes replay real frames and real requests,
and test/examples.test.js checks that every value in them is one the
package can actually encode.
vallox-dashboard.json additionally needs
node-red-dashboard (1.x, the ui_* nodes)
installed, and expects the bus on its input — replace the placeholder with your serial in or
mqtt in. It writes Select and Flags6 by reading the cached value, changing one field and
writing it back, which is the pattern the protocol requires for bit fields.
Two request shapes go to the vallox node. GET asks the unit for a register, SET writes one:
msg.payload = { request: 'GET', variable: 'TemperatureInside' };
msg.payload = { request: 'SET', variable: 'FanSpeed', value: 4 };Either way the node emits the resulting telegram on output 2; wire that into valloxtx. The
answer to a GET arrives like any other bus traffic — through valloxrx, into the node's cache.
A SET aimed at a read-only variable goes to output 3 instead, and no telegram is produced.
Command bytes and the writable/read-only split are the protocol's own, cross-checked against
doc/vallox-rs485-protocol.md and
doc/protocol.txt by the test suite.
| Variable | Command | Value | Meaning |
|---|---|---|---|
BasicHumidityLevel |
0xae |
%RH | Basic humidity level (AEH), the threshold for humidity-driven boosting. |
CellDefrostingHysteresis |
0xb2 |
°C | Anti-freeze hysteresis (B2H), byte = °C × 3. |
CO2SetPointLower |
0xb4 |
ppm, low byte | Lower byte of the 16-bit CO2 setpoint (B4H). |
CO2SetPointUpper |
0xb3 |
ppm, high byte | Upper byte of the 16-bit CO2 setpoint (B3H). |
DCFanInputAdjustment |
0xb0 |
% | DC supply air fan control setpoint (B0H). |
DCFanOutputAdjustment |
0xb1 |
% | DC exhaust fan control setpoint (B1H). |
FanSpeed |
0x29 |
1 - 8 | Current fan speed (29H). Encoded on the wire as 01H,03H,07H,0FH,1FH,3FH,7FH,FFH. |
FanSpeedMax |
0xa5 |
1 - 8 | Highest speed the unit may select while adjusting (A5H). |
FanSpeedMin |
0xa9 |
1 - 8 | Basic fan speed (A9H). The doc calls this "basic", not "minimum". |
Flags6 |
0x71 |
object or byte | 71H. Set FirePlaceSwitchActivator (bit 5) to start the fireplace/booster; bits 4 and 6 are read-only. |
HeatingSetPoint |
0xa4 |
°C | Post-heating setpoint (A4H), NTC scale. |
HRCBypass |
0xaf |
°C | Heat-recovery cell bypass temperature (AFH), NTC scale. |
InputFanStop |
0xa8 |
°C | Supply air fan stops below this temperature (A8H), NTC scale. |
MaintenanceMonthCounter |
0xab |
months | Months remaining until the next service alarm (ABH), counts down. |
PostHeatingOffTime |
0x56 |
% | Post-heating off-time counter (56H), byte = % × 2.5. |
PostHeatingOnCounter |
0x55 |
% | Post-heating on-time counter (55H), byte = % × 2.5. |
PreHeatingSetPoint |
0xa7 |
°C | Pre-heating switching temperature (A7H), NTC scale. |
Program |
0xaa |
object or byte | Adjustment interval and mode bits (AAH). Write back a decoded object or a raw byte. |
Program2 |
0xb5 |
object or byte | Max speed limit mode (B5H). Write back a decoded object or a raw byte. |
Resume |
0x8f |
0 | Allow modules to transmit (8FH). Write-only; DATA is always 0. |
Select |
0xa3 |
object or byte | Panel keys and indicator lamps (A3H). Bits 0-3 (power, CO2, %RH, post-heating) are writable; 4-7 are read-only lamps. |
ServiceReminder |
0xa6 |
months | Service reminder interval (A6H). |
Suspend |
0x91 |
0 | Prohibit modules from transmitting (91H). Write-only; DATA is always 0. |
| Variable | Command | Value | Meaning |
|---|---|---|---|
CO2High |
0x2b |
ppm, high byte | Upper byte of the measured 16-bit CO2 concentration (2BH). |
CO2Low |
0x2c |
ppm, low byte | Lower byte of the measured 16-bit CO2 concentration (2CH). |
CurrentIncoming |
0x2e |
raw 0 - 255 | Current/voltage message to the machine (2EH). |
FirePlaceBoosterCounter |
0x79 |
minutes | Remaining minutes of the fireplace/booster function (79H), counts down. Start the function via Flags6 bit 5. |
Flags1 |
0x6c |
object | 6CH. Not described in the protocol doc; decoded as Bit0 - Bit7. |
Flags2 |
0x6d |
object | Speed requests and alarms (6DH), incl. CO2Alarm, FrostAlarm. |
Flags3 |
0x6e |
object | 6EH. Not described in the protocol doc; decoded as Bit0 - Bit7. |
Flags4 |
0x6f |
object | 6FH: FrostAlarmWaterRadiator, SlaveMasterSelection. |
Flags5 |
0x70 |
object | 70H: PreHeatingStatus. |
Humidity |
0x2a |
%RH | Highest measured relative humidity of the two sensors (2AH). |
HumiditySensor1 |
0x2f |
%RH | Relative humidity measured by sensor 1 (2FH). |
HumiditySensor2 |
0x30 |
%RH | Relative humidity measured by sensor 2 (30H). |
InstalledCO2Sensors |
0x2d |
object | Which CO2 sensors are fitted (2DH), Sensor1Installed - 5. |
IoPortFanSpeedRelays |
0x06 |
object | Fan speed relay states (06H), FanSpeedRelay1 - 8. |
IoPortMultiPurpose1 |
0x07 |
object | I/O port 1 (07H); only PostHeatingOn (bit 5) is defined. |
IoPortMultiPurpose2 |
0x08 |
object | I/O port 2 (08H): damper, fault relay, fans, pre-heating, fireplace booster. |
LastErrorNumber |
0x36 |
text | Last fault, decoded to a sentence (36H); unknown codes come through as 0x... |
PostHeatingTargetValue |
0x57 |
°C | Target temperature of the air blown into the zone (57H), NTC scale. |
TemperatureExhaust |
0x33 |
°C | Exhaust air temperature (33H), NTC scale. |
TemperatureIncoming |
0x35 |
°C | Supply air temperature (35H), NTC scale. |
TemperatureInside |
0x34 |
°C | Extract air temperature (34H), NTC scale. |
TemperatureOutside |
0x32 |
°C | Outdoor temperature (32H), NTC scale. |
Variables whose value is an object decode to the fields below. Entries named BitN are positions
the protocol document leaves undefined; they are still reported, which is what lets a decoded
object be written straight back without losing the bits the unit had set.
| Variable | Fields (bit 0 → 7) |
|---|---|
IoPortFanSpeedRelays |
FanSpeedRelay1, FanSpeedRelay2, FanSpeedRelay3, FanSpeedRelay4, FanSpeedRelay5, FanSpeedRelay6, FanSpeedRelay7, FanSpeedRelay8 |
IoPortMultiPurpose1 |
Bit0, Bit1, Bit2, Bit3, Bit4, PostHeatingOn, Bit6, Bit7 |
IoPortMultiPurpose2 |
Bit0, DamperMotorPosition, FaultSignalRelayClosed, SupplyFanOff, PreHeatingOn, ExhaustFanOff, FireplaceBoosterClosed, Bit7 |
InstalledCO2Sensors |
Bit0, Sensor1Installed, Sensor2Installed, Sensor3Installed, Sensor4Installed, Sensor5Installed, Bit6, Bit7 |
Flags1 |
Bit0, Bit1, Bit2, Bit3, Bit4, Bit5, Bit6, Bit7 |
Flags2 |
CO2HigherSpeedRequest, CO2LowerSpeedRequest, HumiditySpeedRequest, SwitchSpeedRequest, Bit4, Bit5, CO2Alarm, FrostAlarm |
Flags3 |
Bit0, Bit1, Bit2, Bit3, Bit4, Bit5, Bit6, Bit7 |
Flags4 |
Bit0, Bit1, Bit2, Bit3, FrostAlarmWaterRadiator, Bit5, Bit6, SlaveMasterSelection |
Flags5 |
Bit0, Bit1, Bit2, Bit3, Bit4, Bit5, Bit6, PreHeatingStatus |
Flags6 |
Bit0, Bit1, Bit2, Bit3, RemoteMonitoringControl, FirePlaceSwitchActivator, FirePlaceBoosterStatus, Bit7 |
Select |
PowerState, Co2AdjustState, HumidityAdjustState, HeatingState, FilterGuardIndicator, HeatingIndicator, FaultIndicator, ServiceReminderIndicator |
Program |
AdjustmentIntervalMinutes, AutomaticHumidityLevelSeekerState, BoostSwitchMode, RadiatorType, CascadeAdjust |
Program2 |
MaxSpeedLimitMode, Bit1, Bit2, Bit3, Bit4, Bit5, Bit6, Bit7 |
The master broadcasts seven registers to all panels every 12 seconds — Humidity, CO2High,
CO2Low and the four temperatures. Everything else has to be asked for. Wire the bus through
valloxrx into a vallox node and both paths land in the same cache:
serial in → valloxrx → vallox → debug
↑ ↓
GET requests → valloxtx → serial out
A practical setup polls the registers you care about on a rotation — one every few seconds is plenty, and the panels do the same thing:
// in a function node driven by an inject on a repeat
const registers = ['FanSpeed', 'Select', 'FanSpeedMax', 'HeatingSetPoint', 'LastErrorNumber'];
const i = (context.get('i') || 0) % registers.length;
context.set('i', i + 1);
msg.payload = { request: 'GET', variable: registers[i] };
return msg;Two ways to read the cache:
- Push — tick Send msg on new data and output 1 fires on every state change.
- Pull — send the node any message with an empty payload; the current snapshot arrives on output 1.
The snapshot is a flat object keyed by the variable names in the tables, plus Receiver. Variables
that have not been seen yet are simply absent, so guard with a check rather than assuming a key
exists. A vallox node only accepts frames addressed to its configured receiver or to that
address's group (high nibble), so a panel node at 0x21 also sees frames sent to 0x20.
See examples/vallox-monitor.json for a ready-made flow.
Select, Flags6, Program and Program2 are single bytes holding several settings, and only
some bits may be written. Read the current value, change what you want and write the whole object
back — the decode/encode pair round-trips exactly, so the bits you did not touch are returned
unchanged:
// switch the unit on: Select bit 0
const select = { ...flow.get('valloxState').Select, PowerState: true };
msg.payload = { request: 'SET', variable: 'Select', value: select };
return msg;// start the fireplace/booster function: Flags6 bit 5
const flags = { ...flow.get('valloxState').Flags6, FirePlaceSwitchActivator: true };
msg.payload = { request: 'SET', variable: 'Flags6', value: flags };
return msg;A raw byte works too — value: 0x0d is equivalent to the object form.
SuspendandResumestop and start bus traffic for every module. They exist because the unit uses them around CO2 sensor interaction; sending them yourself is rarely what you want. Thevalloxnode watches for them and refuses to emit telegrams while transmission is prohibited, reporting the refusal on output 3. Because a missed8FHwould otherwise wedge the node, that block also lapses by itself after 10 seconds.FanSpeedMinis the protocol's "basic fan speed" (A9H), not a floor. The name is kept for compatibility with existing flows.Flags1(0x6C) andFlags3(0x6E) are not in the protocol document. They decode to bareBit0-Bit7.IoPortMultiPurpose2(0x08) is treated as read-only although the document leaves bits 3 and 5 unmarked. Those bits stop the supply and exhaust fans, and0x06carries an explicit warning that driving the I/O ports can damage the transformer. UseFanSpeedinstead.Program2bits 1-7 are undefined in the document. They are decoded and written back as-is.- The misspelled names
PostHeastingOnCounterandInstalledC02Sensorsare still accepted as input for backwards compatibility. Decoded messages always use the corrected spelling. - A write is three telegrams, matching what a real control unit sends: to the mainboard with the
checksum repeated (7 bytes), then to all panels (
0x20), then to all mainboards (0x10). This was taken from a capture of an original panel changing the fan speed, and the encoded bytes are identical to it. Untick Write like a panel to send only the telegram to the mainboard; the master propagates the change itself, so that is usually enough. - The configured receiver is also the sender address of outgoing telegrams. Choose an address no physical panel occupies, or two devices will answer to the same address.
CellDefrostingHysteresisfollows the document (03H≈ 1 °C, so byte = °C × 3). Note that thevalloxserialArduino library treatsB2Has an NTC temperature instead; the two disagree.
Author: Karl-Heinz Wind
The MIT License (MIT) Copyright (c) 2023 by Karl-Heinz Wind
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
