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. 