UVR16x2 via CANable (candleLight) → Home Assistant (no C.M.I.) – full guide

Hi all,

I wanted to share how I connected a Technische Alternative UVR16x2 directly to Home Assistant OS using a CANable, without using a C.M.I.

This is a generic guide so it can be adapted to different installations.


Overview

Hardware

  • UVR16x2
  • existing CAN bus
  • CANable (tested with CANable 2.0)
  • Home Assistant OS (e.g. Raspberry Pi)

Software

  • Home Assistant OS
  • Mosquitto MQTT Broker
  • MQTT Integration
  • Advanced SSH & Web Terminal Add-on

Flashing CANable (mandatory)

The CANable must run candleLight firmware (gs_usb), otherwise you will not get a native CAN interface.

I used the official web flasher:

https://candlelight-fw.github.io/flash.html

Select:

  • Device: CANable
  • Firmware: candleLight
  • Mode: normal flash

After flashing, the device should appear as:

can0

instead of /dev/ttyACM0.


Architecture

UVR16x2 → CAN → CANable → Python → MQTT → Home Assistant

Important concepts

  • Uses native SocketCAN (can0)
  • UVR16x2 provides values via configured CAN outputs
  • Python script converts CAN frames → MQTT topics

Common pitfalls

BusyBox ip vs iproute2

The default Home Assistant environment uses BusyBox tools, which do not support CAN interface setup properly.

Typical error:

ip: either "dev" is duplicate, or "type" is garbage

Fix:

apk update
apk add iproute2 can-utils python3 py3-pip py3-virtualenv nano

Always use:

/sbin/ip

MQTT authentication

If MQTT is secured, credentials must be used in the Python script.


Add-on configuration is NOT YAML

The Advanced SSH & Web Terminal config UI is form-based.

  • each package = one entry
  • each command = one entry

Step 1 – Configure CAN interface

/sbin/ip link set can0 down
/sbin/ip link set can0 type can bitrate YOUR_BITRATE
/sbin/ip link set can0 up
/sbin/ip link show can0

Step 2 – Verify CAN traffic

candump -L can0

You should see continuous frames.


Step 3 – Python environment

python3 -m venv /config/uvr_venv
source /config/uvr_venv/bin/activate
pip install python-can paho-mqtt

Step 4 – CAN → MQTT script

Save as:

/config/uvr_can2mqtt.py
import can
import paho.mqtt.client as mqtt 

MQTT_HOST = "127.0.0.1"
MQTT_PORT = 1883
MQTT_USER = "YOUR_USER"
MQTT_PASS = "YOUR_PASSWORD"

bus = can.interface.Bus(channel="can0", interface="socketcan")

client = mqtt.Client()
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()

def decode(data, index):
    raw = int.from_bytes(data[index:index+2], "little", signed=True)
    return round(raw / 10.0, 1)

print("UVR bridge started")

while True:
    msg = bus.recv()

    if msg is None:
        continue

    if msg.arbitration_id == 0x22C:
        t1 = decode(msg.data, 0)
        t2 = decode(msg.data, 2)
        t3 = decode(msg.data, 4)

        client.publish("uvr/sensor1", t1)
        client.publish("uvr/sensor2", t2)
        client.publish("uvr/sensor3", t3)

        print(t1, t2, t3)

Adjust CAN IDs and mapping based on your UVR configuration.


Step 5 – Test

/config/uvr_venv/bin/python /config/uvr_can2mqtt.py

Step 6 – Verify MQTT

mosquitto_sub -h 127.0.0.1 -u YOUR_USER -P YOUR_PASSWORD -t "uvr/#" -v

Step 7 – Home Assistant sensors

- platform: mqtt
  name: "Temperature 1"
  state_topic: "uvr/sensor1"

- platform: mqtt
  name: "Temperature 2"
  state_topic: "uvr/sensor2"

- platform: mqtt
  name: "Temperature 3"
  state_topic: "uvr/sensor3"

Step 8 – Autostart

Packages

  • iproute2
  • can-utils
  • python3
  • py3-pip
  • py3-virtualenv

Init Commands

  • /sbin/ip link set can0 down || true
  • /sbin/ip link set can0 type can bitrate YOUR_BITRATE || true
  • /sbin/ip link set can0 up || true
  • /config/uvr_venv/bin/python /config/uvr_can2mqtt.py > /config/uvr_can2mqtt.log 2>&1 &

Restart add-on afterwards.


Verification

ps aux | grep uvr_can2mqtt
tail -f /config/uvr_can2mqtt.log

Result

  • Automatic startup
  • CAN data in MQTT
  • Full Home Assistant integration
  • No C.M.I. needed

Notes

  • CAN IDs depend on your UVR configuration
  • values often use little-endian 16-bit scaling
  • extend mapping step by step

Hope this helps 👍

1 Like

Update / Corrections (after running this for a while)

A small follow-up to my own guide. After this had been live for a bit I noticed the values were subtly wrong and occasionally jumping, and digging into it (plus the original TA CANopen docs) turned up a few things the first post got wrong or left too vague. The transport part above is fine — flashing, SocketCAN, the BusyBox/iproute2 fix all still apply. The corrections are about decoding, persistence and robustness. Posting them here so nobody else burns an evening on the same traps.


Correction 1 — CAN IDs are not arbitrary, and "use 0x22C" was bad advice

In Step 4 I hardcoded 0x22C and told you to "adjust based on your UVR config." That undersells the most important part. The TA identifier layout is fully deterministic (from the TA Identifierzuordnung doc):

CAN ID Contents
0x180 + node digital network outputs 1–16
0x200 + node analog network outputs 1–4
0x280 + node analog network outputs 5–8
0x300 + node analog network outputs 9–12
0x380 + node analog network outputs 13–16

So the ID itself tells you the node and which output slots are inside the frame:

node          = arbitration_id & 0x7F
function_code = arbitration_id & 0x780

Each analog frame is four signed little-endian INT16 values (slots 0–3 = bytes 0–1, 2–3, 4–5, 6–7). No guessing of byte positions needed — work it out from the table.


Correction 2 — The multi-node trap (this was my real bug)

This is the big one. On a typical installation there is more than one TA device on the bus (extra UVRs, RSM610, CAN-I/O modules), and every node broadcasts its own outputs. My 0x22C was not even my main controller — it was a different UVR's analog outputs 1–4 (0x22C = node 44). My script was publishing another controller's buffer into my topics, and whenever a second node's frame arrived it overwrote the values → the "jumping" I was seeing.

Rules that fixed it:

  • Decode the ID to find which node it is. Only read frames from your controller's node.
  • Never map two different CAN IDs to the same MQTT topic.
  • Find your controller's node number first (e.g. by the IDs it sends, or its CAN settings menu). Mine turned out to be node 37, so its analog outputs live on 0x225 (1–4) and 0x2A5 (5–8).

Correction 3 — A decoder to map deterministically

Instead of guessing, run this for a bit and compare each value against the UVR display to lock the mapping. It labels every frame with node + output number:

#!/usr/bin/env python3
import time, can
ANALOG = {0x200: 1, 0x280: 5, 0x300: 9, 0x380: 13}

def analog4(d):
    return [round(int.from_bytes(d[i:i+2], "little", signed=True)/10.0, 1) for i in range(0, 8, 2)]

bus = can.interface.Bus(channel="can0", interface="socketcan")
latest = {}
t_end = time.time() + 30
while time.time() < t_end:
    m = bus.recv(timeout=1)
    if m and len(m.data) == 8:
        latest[m.arbitration_id] = bytes(m.data)
for arb in sorted(latest):
    node, fcode = arb & 0x7F, arb & 0x780
    if fcode in ANALOG:
        base = ANALOG[fcode]
        vals = analog4(latest[arb])
        cells = "  ".join(f"AO{base+k}={vals[k]}" for k in range(4))
        print(f"0x{arb:03X}  node {node}  analog {base}-{base+3}  {cells}")

Note: TA sends analog outputs on change, so a slow-moving sensor (buffer) may not appear in a short window. Let it run a few minutes, and for fast values (return temp etc.) compare screen vs. output at the same moment — comparing an active log against a standby snapshot will look wrong even when the mapping is correct.


Correction 4 — Scaling is /10 only for temperatures

My note "values often use little-endian 16-bit scaling" was too loose. The INT16 broadcast carries the raw value with no unit/scale metadata. For temperatures it's 0.1 °C, so /10. Other quantities (kWh, %, l/h, kW) use different factors — set the scale per output, don't assume /10 everywhere.


Correction 5 — Don't use "plausibility" filters

My early attempts grabbed "the first value between 15 and 40" for a flow temp. That latches onto the wrong slot the moment a real value leaves the window, and is another source of jumping. Map the exact (CAN ID, slot) instead.


Correction 6 — The Python venv must own its pip (HAOS persistence trap)

Step 3 worked for me… until a reboot, when the bridge died with ModuleNotFoundError: No module named 'can'. The SSH add-on is a container: only /config persists; the rest is rebuilt on every start. If the venv was created without its own pip, pip install silently uses the container's system pip and the packages land outside /config — gone after a reboot.

Fix — install into the venv explicitly and verify the location:

/config/uvr_venv/bin/python -m ensurepip --upgrade
/config/uvr_venv/bin/pip install python-can paho-mqtt
/config/uvr_venv/bin/python -c "import can; print(can.__file__)"

The printed path must start with /config/uvr_venv/…. If it points at /usr/…, it won't survive a reboot.


Correction 7 — paho-mqtt 2.x changed the API

The pip install now pulls paho-mqtt 2.x, where bare mqtt.Client() no longer works. Use:

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)

Correction 8 — Robustness: discovery + availability (replaces Step 7)

Two improvements that make this much nicer to live with:

  • MQTT Discovery — publish a retained homeassistant/sensor/<id>/config per sensor and HA creates the entities automatically (correct unit, device_class, grouped under one device). This makes the manual sensor: YAML in Step 7 unnecessary — and note the old - platform: mqtt sensor format is deprecated anyway.
  • Last Will (availability) — set an MQTT LWT on a status topic and reference it as availability_topic. If the bridge ever stops, HA shows the sensors unavailable instead of a frozen value. Without this, a dead script + retained values = a dashboard that confidently shows hours-old numbers (I had a 22h-old value once and didn't notice).

Correction 9 — Find your bitrate, don't assume

I left YOUR_BITRATE as a placeholder. If the interface is already up and receiving, just read it off:

ip -details link show can0

Look for the bitrate line. TA defaults to 50000, but longer bus runs are often set to 20000 (mine was 20000 — assuming 50000 would have silently failed). Put the value you read into the init command.


Correction 10 — A note on SDO libraries

If you find a third-party library that reads values via SDO (request/response, the 0x600/0x580 range): several people have found TA changed the CAN protocol around spring 2024, which breaks SDO-based reads on the UVR16x2 (it connects and IDs the device, but value reads come back empty/garbled). The broadcast / network-output method in this guide doesn't rely on SDO, so it's the robust route. (And if you want zero decoding at all and don't mind the hardware, a C.M.I. + the "Technische Alternative C.M.I." HACS integration gives named, correctly-scaled values out of the box.)


Corrected reference script

Putting it together — deterministic mapping, discovery, availability. Replace the node/IDs/slots with what your decoder run shows, and keep the credentials as placeholders / don't commit real ones:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, signal, sys, can
import paho.mqtt.client as mqtt

MQTT_HOST, MQTT_PORT = "127.0.0.1", 1883
MQTT_USER, MQTT_PASS = "YOUR_USER", "YOUR_PASSWORD"
CAN_CHANNEL = "can0"
AVAIL_TOPIC = "uvr/status"

# (can_id, slot, object_id, name, state_topic, scale) -- from YOUR decoder run
SENSORS = [
    (0x225, 0, "puffer_oben",  "Puffer oben",  "uvr/puffer_oben",  0.1),
    (0x225, 1, "puffer_mitte", "Puffer mitte", "uvr/puffer_mitte", 0.1),
    (0x225, 2, "puffer_unten", "Puffer unten", "uvr/puffer_unten", 0.1),
    (0x225, 3, "vl_nahwaerme", "VL Nahwaerme", "uvr/vl_nahwaerme", 0.1),
    (0x2A5, 0, "rl_nahwaerme", "RL Nahwaerme", "uvr/rl_nahwaerme", 0.1),
]

LOOKUP = {}
for cid, slot, oid, name, topic, scale in SENSORS:
    LOOKUP.setdefault(cid, []).append((slot, topic, scale))

def decode_slot(data, slot, scale):
    i = slot * 2
    return round(int.from_bytes(data[i:i+2], "little", signed=True) * scale, 1)

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.will_set(AVAIL_TOPIC, "offline", retain=True)
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()
client.publish(AVAIL_TOPIC, "online", retain=True)

device = {"identifiers": ["uvr16x2"], "name": "UVR16x2",
          "manufacturer": "Technische Alternative", "model": "UVR16x2"}
for cid, slot, oid, name, topic, scale in SENSORS:
    cfg = {"name": name, "unique_id": f"uvr_{oid}", "state_topic": topic,
           "unit_of_measurement": "°C", "device_class": "temperature",
           "state_class": "measurement", "availability_topic": AVAIL_TOPIC,
           "device": device}
    client.publish(f"homeassistant/sensor/uvr_{oid}/config", json.dumps(cfg), retain=True)

bus = can.interface.Bus(channel=CAN_CHANNEL, interface="socketcan")

def shutdown(*_):
    try:
        client.publish(AVAIL_TOPIC, "offline", retain=True)
        client.loop_stop(); client.disconnect()
    except Exception: pass
    try: bus.shutdown()
    except Exception: pass
    sys.exit(0)

signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
print("UVR bridge running")

while True:
    msg = bus.recv()
    if msg is None or len(msg.data) != 8:
        continue
    for slot, topic, scale in LOOKUP.get(msg.arbitration_id, []):
        client.publish(topic, decode_slot(msg.data, slot, scale), retain=True)

TL;DR of the fixes: decode the CAN ID to get node + output slots; only read your own node; map (ID, slot) exactly; /10 for temps; install packages into the venv; use paho 2.x API; add discovery + a Last Will; and read your real bitrate. With that, the data is stable and survives reboots. :+1: