Skip to content
womatPublic

About

Read the DL-Bus of a Technische Alternative UVR42 or UVR31 heating controller on a Raspberry Pi – live web page with temperature trends, MQTT and REST API. Wired through an optocoupler, no setting for bit rate or polarity. Single Go binary, no cloud.

Topics

Resources

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

tadl

Read the DL-Bus of a Technische Alternative UVR42 or UVR31 heating controller on a Raspberry Pi — and see the temperatures live.

CI Release License: MIT Go Raspberry Pi

🇩🇪 Deutsche Kurzfassung

tadl web page: four named temperatures with range bars and 15-minute trend, the outputs, and the state of the DL-Bus   The same page on a phone

Got a Pi and a UVR42 or UVR31? The Quick start gets you from download to the live page; the Wiring needs an optocoupler and a resistor.

The solar and heating controllers UVR42 and UVR31 of Technische Alternative send their temperatures and output states on the DL-Bus, a two-wire data line originally meant for TA's own data loggers. Connect that line through an optocoupler to a GPIO pin of a Raspberry Pi, and tadl turns it into readings:

  • the temperatures of all sensors and the state of the outputs (pumps, valves),
  • published via MQTT on every significant change and at a fixed interval, ready for Node-RED, Home Assistant, ioBroker or openHAB,
  • shown live on a built-in web page, with your own sensor names, a bar per temperature and its trend over the last 15 minutes,
  • available through an HTTPS REST API for scripts and monitoring.

No cloud, no database, no runtime: a single binary, configured with one YAML file.

Features

  • Decodes DL-Bus frames of the UVR42 and the UVR31
  • Any bit rate, no setting: the Manchester decoder recovers the controller's clock from the signal and follows it, also when the controller's clock is off or drifts
  • Line polarity detected at every frame, so an inverting optocoupler needs no setting
  • Web page: temperatures with names, range bars and 15-minute trend, outputs, a receive LED, signal state, line polarity and the errors of the last 24 hours — readable on a phone, light and dark mode
  • MQTT publishing at an interval plus a delta trigger (minDeltaTemp); a changed output is sent at once
  • HTTPS REST API (TLS 1.3) with one bearer token per caller, stored only as a SHA-256 hash and managed with tadl token; IP allowlist / blocklist; the log names who calls
  • Hot reload of the configuration via SIGHUP; a broken file is refused, the running configuration stays
  • Controller emulator dlbussim for testing without a controller
  • Release builds for every Raspberry Pi architecture, from the Pi Zero (ARMv6) to 64-bit systems

Supported devices

Controller Device ID Temperatures Outputs Bit rate One frame every
UVR42 0x10 4 2 50 Hz 2.32 s
UVR31 0x30 3 1 50 Hz 1.92 s

The protocol is described in Technische Alternative's DL-Bus protocol description v1.7 (German): Manchester code, a SYNC of 16 high bits, bytes with start and stop bit, LSB first, and the frame layout of every controller. Temperatures are sent in 0.1 °C; a value outside −50 … 300 °C, such as from a broken or disconnected sensor, is left out.

Other TA controllers (UVR1611, UVR61-3, ESR21, UVR64, …) use the same bus, and tadl reads their signal at any bit rate, but it does not decode their frames: the web page then shows Signal, no valid frames.

tadl runs on every Raspberry Pi with a 40-pin header under a current Raspberry Pi OS; it reads the GPIO through the Linux GPIO character device.


Quick start

1. Download the archive for your Pi from the latest release:

Archive Raspberry Pi model
linux_armv6 Pi 1 and Zero (1st gen); also runs on every newer Pi
linux_armv7 Pi 2 / 3 / 4 / 5 / Zero 2 W with a 32-bit OS
linux_arm64 Pi 3 / 4 / 5 / 400 / Zero 2 W with a 64-bit OS
VERSION=3.0.2 ARCH=arm64        # see the release page for the latest version
BASE=https://github.com/womat/tadl/releases/download/v$VERSION
curl -LO $BASE/tadl_${VERSION}_linux_$ARCH.tar.gz -LO $BASE/checksums.txt
sha256sum -c checksums.txt --ignore-missing
tar xzf tadl_${VERSION}_linux_$ARCH.tar.gz

2. Install binary, example configuration and a certificate. Everything belongs to root; the service user tadl may read etc/ and write nothing:

sudo groupadd -r -f tadl
sudo useradd -r -s /usr/sbin/nologin -g tadl tadl
sudo usermod -aG gpio tadl
sudo install -d -o root -g root -m 755 /opt/tadl /opt/tadl/bin
sudo install -d -o root -g tadl -m 750 /opt/tadl/etc

sudo install -o root -g root -m 755 tadl /opt/tadl/bin/
sudo install -o root -g tadl -m 640 config/config.yaml /opt/tadl/etc/
sudo openssl req -x509 -nodes -newkey rsa:2048 -days 825 \
  -keyout /opt/tadl/etc/key.pem -out /opt/tadl/etc/cert.pem -subj "/CN=$(hostname)"
sudo chown root:tadl /opt/tadl/etc/key.pem /opt/tadl/etc/cert.pem
sudo chmod 640 /opt/tadl/etc/key.pem

Why root: whoever can write config.yaml or the token file can add a token of their own, and whoever can write bin/tadl can replace the program. tadl itself needs neither.

3. Configure /opt/tadl/etc/config.yaml: set env: prod, the controller type, the GPIO pin and your MQTT broker (or delete the mqtt block) — see Configuration and Wiring. Then create a token for each caller, here the browser:

sudo /opt/tadl/bin/tadl token add browser

It prints the token once; keep it, e.g. in a password manager. See Tokens.

4. Start it as a service, with the unit from the archive, and open the firewall:

sudo install -m 644 contrib/tadl.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tadl
sudo ufw allow 8443/tcp          # if ufw is active
journalctl -u tadl -n 20         # "Module started successfully"

The unit runs tadl in a sandbox: no capabilities, a read-only system, only the GPIO chip as a device (systemd-analyze security tadl rates it about 1.4 instead of 9.2 without).

5. Open https://<your-pi>:8443/, accept the self-signed certificate and enter the token.


Wiring

The DL-Bus is a two-wire line: the data line (DL) and ground. The controller drives the data line with 12 V and also powers TA's data loggers and DL sensors from it (at most about 40 mA). Never connect it straight to a GPIO pin: the GPIO pins take 3.3 V at most.

With an optocoupler

An optocoupler, such as a PC817 (also sold as LTV-817 or EL817), keeps the bus apart from the Pi and converts the level. The bus drives the optocoupler's LED, and its transistor pulls the GPIO to ground:

PC817 optocoupler U1: the DL line of the controller (12 V) drives the LED (pin 1 anode, pin 2 cathode) through the reverse-polarity diode D1 and the 2.2 kΩ series resistor R1, the cathode goes to the ground of the bus; the transistor (pin 4 collector, pin 3 emitter) connects GPIO4 (header pin 7), pulled up to 3V3 (pin 1) by the 10 kΩ R2 or the internal pull-up, to GND (pin 9); a dashed line marks the galvanic isolation

Bus / Pi side Connection
DL (data line) through a series resistor to the LED anode, PC817 pin 1
Ground of the bus LED cathode, PC817 pin 2
GPIO (BCM, dlbus.gpio) collector, PC817 pin 4
GND of the Pi emitter, PC817 pin 3
3V3 of the Pi pull-up to the GPIO: an external resistor of about 10 kΩ, or gpioTermination: pullup

The series resistor sets the LED current: about (12 V − 1.2 V) / R, so 2.2 kΩ gives about 5 mA, which switches a PC817 reliably and takes little from the bus. Check the voltage on your bus before you size the resistor; a diode such as a 1N4148 in series protects the LED against a swapped connection. The bit rate of 50 Hz is far below what a PC817 can switch.

This stage inverts the signal: a high bus pulls the GPIO low. tadl detects the polarity at the SYNC of every frame, so no setting is needed; the web page and /health show it as inverted. A non-inverting interface works just as well.

An open-collector output like the PC817's needs a pull-up: either the resistor to 3V3 above, or dlbus.gpioTermination: pullup for the Pi's internal one (about 50 kΩ). Keep debounceTime: 0s, see Configuration.

Test bench without a controller

dlbussim drives a second GPIO pin of the same Pi with DL-Bus frames. Wired through the same optocoupler, the test signal is inverted like a real bus:

Test bench on one Raspberry Pi: dlbussim drives GPIO21 (pin 40), which drives the LED of the PC817 through the 470 Ω R1 to GND (pin 34); the transistor connects GPIO20 (pin 38), tadl's input, pulled up to 3V3 by the 10 kΩ R2 or the internal pull-up, to GND (pin 39)

Run dlbussim -gpio 21 and set tadl's dlbus.gpio: 20. A 3.3 V source like this may also be wired straight to the input with a plain wire (GPIO21 to GPIO20); a real DL-Bus never.


Web UI

Open https://<host>:8443/ in a browser and enter a token once (tadl token add browser); it is kept in that browser's local storage only and sent as a bearer token to /health. Sign out removes it, and a rejected token brings the prompt back. The page refreshes every 3 s while the tab is visible and shows:

  • the temperatures of the controller, each with a bar on its own range and the change over the last 15 minutes, and the outputs on or off. Names and ranges come from datalogger.sensors;
  • whether data arrives: a receive LED flashes on every valid frame, and the signal line reads Receiving, Signal, no valid frames (wrong datalogger.type), Signal not decoded (edges at the input, but no frames) or No signal (no edges either);
  • the bit rate the decoder uses, marked when it is more than 10 % off the controller's, the edges per second at the input, the line polarity, the number of errors in the last 24 hours with the time of the last one, and the watchdog's decoder resets, if there were any.

The browser tab reads tadl · <host>, so several devices can be told apart. The page itself is public and contains no data. It is a single file embedded in the binary, with no external scripts or fonts, so it works without internet access; it follows the system's dark mode and works on a phone.

The 15-minute trend and the 24-hour error count are kept in memory: they start again after a restart or a reload.


Configuration

Default location: /opt/tadl/etc/config.yaml

  • ${VAR} is replaced with the environment variable VAR, e.g. connection: tcp://tadl:${MQTT_PASSWORD}@broker:1883; an unset variable becomes empty. Only this form is expanded; a bare $ stays as it is, so passwords may contain it.
  • The configuration is validated on start and before every reload: env is dev or prod, tokenFile is set and readable, logLevel is known, the port is 1–65535, publishInterval is at least 1s, topicPrefix is set when MQTT is enabled, minDeltaTemp, debounceTime and bitClock are not negative, the GPIO is between 2 and 27, gpioTermination and type are known, and every datalogger.sensors entry names a key of the configured controller.
  • Unknown keys are rejected, so a misspelled or renamed setting cannot silently fall back to its default.
  • Durations need a unit (10ms, 5s, 1m); a bare number, 0 included, is rejected.
# =============================================================================
# app configuration
# =============================================================================

# logLevel defines the minimum log level.
# Messages with at least this level are logged.
# Allowed values: debug | info | warn | error
logLevel: info

# logDestination defines where logs are written to.
# Supported values: stdout | stderr | /path/to/logfile
logDestination: stdout

# environment: dev | prod
env: dev

# =============================================================================
# Webserver configuration (HTTPS)
# =============================================================================
webserver:
  # Host address the HTTPS server listens on (0.0.0.0 = all interfaces)
  listenHost: 0.0.0.0

  # Port the HTTPS server listens on
  listenPort: 8443

  # Bearer tokens accepted on the protected endpoints, one "<name> <sha256>" per line.
  # Manage it with "tadl token add|remove|list"; a relative path is relative to this file.
  # The file holds hashes only, but whoever can write it can add a token: owner root.
  tokenFile: /opt/tadl/etc/token-hashes

  # TLS private key file
  keyFile: /opt/tadl/etc/key.pem

  # TLS certificate file
  certFile: /opt/tadl/etc/cert.pem

  # Blocked IP addresses or networks (empty = none blocked)
  # An entry that is no address or network is a config error, also in allowedIPs.
  # Examples: 192.168.0.1, 192.168.0.0/16, 10.0.0.0/8
  blockedIPs: [ ]
  #  - 192.168.0.1
  #  - 192.168.0.0/16

  # Allowed IP addresses or networks (empty = all allowed)
  # Note: ::1 is the IPv6 loopback address
  # Examples: 127.0.0.1, ::1, 192.168.0.0/16
  allowedIPs: [ ]
  #  - 127.0.0.1
  #  - ::1
  #  - 192.168.0.0/16

# =============================================================================
# Datalogger configuration
# =============================================================================
datalogger:
  # Supported types: uvr42 | uvr31
  type: uvr42
  # Optional: name sent as "device" in every telegram; default is the type.
  # name: solar

  # Optional: names and bar ranges for the web UI, keyed like the data
  # (uvr42: temperature1-4, out1-2; uvr31: temperature1-3, out1).
  # Without an entry the web UI shows T1, A1 ... and a range of -20 ... 150 °C.
  # The API (/data) and MQTT are not affected.
  # sensors:
  #   temperature1: { label: Collector, min: -20, max: 150 }
  #   temperature2: { label: Tank top, min: 0, max: 100 }
  #   temperature4: { label: Boiler room, min: -5, max: 25 }
  #   out1: { label: Solar pump }

# =============================================================================
# DL-Bus configuration
# =============================================================================
dlbus:
  # GPIO input pin for DL-Bus signal
  gpio: 4

  # Debounce period as Go duration string  (e.g. 5ms, 0s = disabled)
  # Keep 0: the kernel rounds the debounce up to its timer tick (4-10ms on a
  # Raspberry Pi) and then swallows the edges of short half-bits.
  debounceTime: 0s

  # GPIO line termination: pullup (default) | pulldown | none
  # The optocoupler pulls the line to GND, so it needs a pull-up: the Pi's
  # internal one (pullup) or an external resistor (none). pullup also works
  # alongside an external resistor.
  gpioTermination: pullup

  # DL-Bus bit clock in Hz, 0 = recover it from the signal (recommended).
  # With 0 tadl reads any controller whatever its bit rate (UVR31/UVR42: 50 Hz,
  # UVR1611/UVR61-3/ESR21: 488 Hz) and follows a controller whose clock is off
  # or drifts. A fixed value must match the controller within 25 %.
  # A fixed value is the more robust choice when the controller is known: with
  # 0, a burst of noise can make the decoder lock on twice the rate, and up to
  # tadl 3.0.3 it stayed there (see Troubleshooting).
  # The line polarity needs no setting either: an optocoupler inverts the
  # signal, and tadl detects that at the SYNC of every frame.
  bitClock: 0

# =============================================================================
# MQTT configuration (on when this block is present; delete or comment it out to run
# without MQTT)
# =============================================================================
mqtt:
  # Broker connection string, required
  # Format: tcp://host:port
  connection: "tcp://mqtt.example.com:1883"

  # Retain messages on the broker
  retained: false

  # MQTT topic prefix to publish measurements to
  topicPrefix: test/uvr42

  # Publish interval as Go duration string (e.g. 60s), at least 1s.
  # The current values are published at this interval in any case.
  publishInterval: 60s

  # Minimum temperature change in Kelvin since the last publish that triggers an
  # immediate publish. A changed output is always published at once.
  # 0 = temperatures are published only by interval (see publishInterval)
  minDeltaTemp: 0.5

Reference

Key Type Default Description
logLevel string info debug, info, warn (or warning), error
logDestination string stdout stdout, stderr or a file path
env string dev dev or prod; see TLS certificate
webserver.listenHost string 0.0.0.0 Address the HTTPS server listens on
webserver.listenPort int 8443 Port the HTTPS server listens on
webserver.tokenFile string — Required. File with the accepted bearer tokens, see Tokens; relative to the config file
webserver.keyFile string — TLS private key file
webserver.certFile string — TLS certificate file
webserver.blockedIPs list empty Addresses or networks (CIDR) that are refused; an entry that is neither is a config error
webserver.allowedIPs list empty Addresses or networks (CIDR) that are allowed; empty allows all; an entry that is neither is a config error
datalogger.type string uvr42 uvr42 or uvr31
datalogger.name string the type Sent as device in every telegram, so a consumer can tell several controllers apart
datalogger.sensors.<key>.label string T1 … / A1 … Name of a temperature or output on the web page
datalogger.sensors.<key>.min, .max float -20, 150 Range of a temperature bar in °C, within −50 … 300; temperatures only
dlbus.gpio int — Required. GPIO of the DL-Bus input, BCM numbering, 2–27
dlbus.debounceTime duration 0s Kernel debounce of the input; keep 0s
dlbus.gpioTermination string pullup pullup, pulldown or none; none only with an external pull-up
dlbus.bitClock int 0 Bit clock in Hz; 0 recovers it from the signal
mqtt block — MQTT is on when the block is present; delete or comment it out to run without MQTT
mqtt.connection string — Required in the block. Broker, e.g. tcp://host:1883 or tcp://user:password@host:1883; a URL that cannot be parsed is a config error
mqtt.retained bool false Broker keeps the last message
mqtt.topicPrefix string — Required in the block. Topic the values are published to
mqtt.publishInterval duration 10s Heartbeat, at least 1s
mqtt.minDeltaTemp float 0 Temperature change in Kelvin that triggers a publish; 0 disables the trigger

The <key> of datalogger.sensors is the key of the value in /data: temperature1 … temperature4, out1, out2 for a UVR42, temperature1 … temperature3, out1 for a UVR31. Sensors without an entry keep the defaults. The broker password, if any, is never written to the log.


MQTT publishing

Telegram

The values of the controller are published as one JSON object on the topic topicPrefix itself (QoS 0); /data returns the same object. A UVR42:

test/uvr42  {"device":"uvr42","out1":true,"out2":false,"temperature1":21.5,"temperature2":45.3,"temperature3":-7.2,"temperature4":0,"timestamp":"2026-10-08T14:32:05+02:00"}
Key Content
device datalogger.name, by default the controller type (uvr42, uvr31)
temperature1 … temperature4 Temperature in °C, 0.1 °C resolution. A sensor out of range (−50 … 300 °C) is left out
out1, out2 Output on (true) or off (false)
timestamp Time the frame was received, RFC 3339 in local time with offset, whole seconds

A UVR31 sends temperature1 … temperature3 and out1.

When the values are published

  • The first frame after a start or reload is published at once.
  • Every frame (one every 2–3 seconds) is compared with the last published one, not with its predecessor, so a slow drift adds up. It is published at once when an output changed, a sensor went out of range or came back, or a temperature changed by at least minDeltaTemp.
  • In any case the current values are published every publishInterval (the heartbeat).
  • Nothing is published while there is no current frame: when the last one is older than three times publishInterval, but at least 30 s, the values count as stale. /data then answers 503.

While the broker is unreachable, tadl keeps reading and reconnects in the background, at the latest every minute. The outage and its end are logged once each, not for every frame. The log does not say why a connect failed, a refused password included: check mqtt.connection and the broker's log when /health stays disconnected.


REST API

Method Path Auth Description
GET / — Web page, see Web UI
GET /version — Application name and version
GET /health Token Runtime metrics, MQTT state, controller values, DL-Bus state and errors
GET /data Token Latest values of the controller; 503 without a current frame

Authentication with a bearer token from tadl token add: Authorization: Bearer <token>. HTTPS is TLS 1.3 only. Errors are returned as {"error": "..."} with the HTTP status (401 without a valid token, 503 from /data while there is no current frame).

# Latest values
curl -k -H "Authorization: Bearer $TOKEN" https://<your-pi>:8443/data

# Application version (no auth required)
curl -k https://<your-pi>:8443/version

# Health check
curl -k -H "Authorization: Bearer $TOKEN" https://<your-pi>:8443/health

/health reports, besides the runtime metrics (app, appVersion, goVersion, hostname, os, uptimeSeconds, memory, goroutines, timestamp), the MQTT connection as mqtt (connected, disconnected — also while reconnecting — or disabled without an mqtt block) with mqttBroker (host and port only — a user or password in mqtt.connection is never sent) and mqttTopic, both left out without an mqtt block, the controller and the bus — everything the web page shows (broker and topic as the tooltip of the MQTT pill):

"datalogger": {
  "type": "uvr42",
  "current": true,
  "lastFrame": "2026-10-08T14:32:05+02:00",
  "lastFrameAgeSeconds": 1.6,
  "temperatures": [
    { "key": "temperature1", "label": "Collector", "value": 118.6, "min": -20, "max": 150, "trend15m": 2.4 },
    { "key": "temperature3", "label": "Tank bottom", "value": null, "min": 0, "max": 100, "trend15m": null }
  ],
  "outputs": [
    { "key": "out1", "label": "Solar pump", "on": true }
  ]
},
"bus": {
  "signal": "receiving",
  "bitRateHz": 50,
  "fixedBitClock": false,
  "nominalBitRateHz": 50,
  "edgeRateHz": 74.6,
  "invertedLine": true,
  "decoderResets": 0,
  "framesReceived": 48213,
  "rejectedFrames": 0,
  "droppedFrames": 0,
  "protocolErrors": 3,
  "droppedEdges": 0,
  "errors24h": { "rejectedFrames": 0, "droppedFrames": 0, "protocolErrors": 3, "droppedEdges": 0, "total": 3 },
  "lastError": "2026-10-08T12:18:05+02:00",
  "lastErrorAgeSeconds": 8040,
  "decoder": "Decoder state: decoding data, Frequency: 50.00 Hz, Buffer overflow count: 0, Resync count: 0"
}
Field Meaning
temperatures[].value null while the sensor is out of range
temperatures[].trend15m Change in Kelvin over the last 15 minutes; null until tadl has run that long
bus.signal receiving (valid frames in the last 10 s), noValidFrames (frames arrive but none fits datalogger.type), notDecoded (edges at the input, but no frames), noSignal (no edges either)
bus.bitRateHz Bit clock the decoder uses; null while it searches for it
bus.fixedBitClock The bit clock is set by dlbus.bitClock, not recovered
bus.nominalBitRateHz Bit rate of the configured controller (UVR42/UVR31: 50)
bus.edgeRateHz Edges per second at the GPIO input over the last 10 s; a DL-Bus at 50 Hz gives 50 to 100
bus.decoderResets Resets of the Manchester decoder by the watchdog since the start, see Troubleshooting
bus.invertedLine The last SYNC arrived inverted, as behind an optocoupler
rejectedFrames Frames of the wrong size or another device, or without a valid temperature
droppedFrames Frames discarded because the reader was busy
protocolErrors Frames discarded for a missing stop bit
droppedEdges GPIO edges lost before decoding

The counters in bus run since the start, errors24h sums the last 24 hours.

With the build tag swagger, the Swagger UI is served at /swagger/; it is meant for development only.


Command-line flags

Flag Default Description
--config /opt/tadl/etc/config.yaml Path to the configuration file
--debug false Enable debug logging to stdout (overrides config)
--version false Print the application version and exit
--about false Print application details and exit
--help false Print the help text and exit

tadl token add|remove|list manages the bearer tokens, see Tokens.

The config file path can also be set via the environment variable CONFIG_FILE; --config wins over it.

tadl --config /opt/tadl/etc/config.yaml
tadl --debug
CONFIG_FILE=/opt/tadl/etc/config.yaml tadl

Tokens

Every caller gets its own token: a browser, Node-RED, a script. tadl keeps only the SHA-256 hash of each, in the file named by webserver.tokenFile, one line per caller:

# /opt/tadl/etc/token-hashes
browser  3f2a…c91e
nodered  8b04…d7a2

The token itself exists only with the caller. Whoever reads the file cannot call tadl with it, so it needs no more protection than the config — but whoever can write it can add a token of their own: keep it root:tadl 0640, like config.yaml. tadl logs the warning "Token file is not protected" at every start and reload when it could change the file or a directory above it.

Command Does
sudo tadl token add <name> Writes the hash, then prints the token once, on stdout alone
sudo tadl token add <name> -dry-run Prints token and line, writes nothing
sudo tadl token remove <name> Deletes the line
sudo tadl token list Prints the names, never a hash
sudo systemctl reload tadl Applies a change

-config works as for the service. A new token file gets 0640 and the group of config.yaml; its owner is whoever runs the command, root with sudo. add refuses a name that is taken and prints no token when it cannot write the file, so a token that is shown but stored nowhere cannot happen. A name is 1–64 letters, digits, ., _ or -.

Rotating without a gap: tadl token add nodered-2, move the caller to the new token, tadl token remove nodered, reload. A lost token is not recovered but replaced the same way.

In the log: at start and after every reload tadl names the tokens it accepts (Bearer tokens loaded … names=[browser nodered]). The first request of each caller from each address is logged at info (user=browser request.client=… firstAccess=true), every rejected one as a warning with its address; the requests in between, the web page polls every 3 s, at debug.


TLS certificate

tadl serves HTTPS only. With env: prod it needs certFile and keyFile; a missing certFile stops the start. The Quick start creates a self-signed pair valid for 825 days, the maximum browsers accept.

With env: dev, tadl falls back to a self-signed certificate compiled into the binary when certFile does not exist, and logs a warning. That certificate's private key ships inside every published release archive, so it is public knowledge: anyone can extract it and impersonate an instance running on the fallback. It exists so a fresh checkout starts during development; never run a reachable device on it.


Hot reload

Send SIGHUP to reload the configuration without restarting the process:

sudo systemctl reload tadl          # requires ExecReload in the unit, see Quick start
# or, independent of the unit file:
sudo systemctl kill -s HUP tadl

A reload also rereads the token file, so a token added or removed with tadl token applies without a restart. The new configuration is loaded and validated before anything is stopped. If it fails, the reload is refused with Config reload rejected, keeping the running configuration in the log and tadl keeps reading with its current settings — fix the file and reload again. A valid file restarts tadl's components with the new settings: the GPIO line is reopened and the MQTT client reconnects, within a few seconds. Some problems only show then, e.g. a missing TLS certificate or a port or GPIO line in use: tadl logs Start with the new configuration failed, continuing with the previous one and starts again with the settings it ran with before.


Testing without a controller

cmd/dlbussim emulates a controller: it drives a GPIO pin with DL-Bus frames back to back, Manchester-encoded, with values and timing set by flags. Connect that pin to tadl's input — directly, since both sides are 3.3 V, or through the optocoupler a real bus would use — and point tadl's dlbus.gpio at the input. Every release archive contains dlbussim next to tadl.

# on the Pi, e.g. GPIO21 drives an optocoupler whose output is GPIO20 (tadl: gpio 20)
./dlbussim -gpio 21 -temps 21.5,45.3,-7.2,0 -out1  # UVR42 at 50 Hz
./dlbussim -gpio 21 -offset 5                      # a controller whose clock is 5 % fast
./dlbussim -gpio 21 -bitClock 488 -frames 20       # 488 Hz, twenty frames
./dlbussim -type uvr31 -temps 20,30,40             # UVR31
Flag Default Description
-gpio 21 GPIO pin (BCM) to drive
-type uvr42 Controller to emulate: uvr42 or uvr31
-bitClock 50 Nominal bit clock in Hz
-offset 0 Deviation of the sender's clock in percent, e.g. 5 or -5
-temps 21.5,45.3,-7.2,0 Temperatures in °C, comma separated (UVR42: 4, UVR31: 3)
-out1, -out2 false Outputs on (-out2 UVR42 only)
-frames 0 Number of frames to send, 0 = until interrupted

GET /data then shows the values sent. The tests in pkg/dlbus run the same chain without hardware: the encoder of womat/manchester, a virtual line (also inverted, with the clock 5 % off), the decoder without a bit clock, the DL-Bus handler and the data logger.


Security

  • The REST API and the web page are served over HTTPS only. Use your own certificate and env: prod on every device, see TLS certificate.
  • Give every caller its own token (tadl token add <name>); everything except /, /version and the TLS handshake needs one. tadl stores only their hashes and logs who calls. tadl only reads the bus: the API cannot change anything on the controller.
  • Keep /opt/tadl owned by root, as in the Quick start, and run the unit from contrib/ with its sandbox.
  • Limit who can reach the port with allowedIPs, e.g. to your home network, and do not expose it to the internet.
  • mqtt.connection may carry a broker password; keep the config file readable for root and the group tadl only (640, as in the Quick start) or pass the password in an environment variable.

Report vulnerabilities privately, see SECURITY.md.


Troubleshooting

The web page shows No signal Not a single edge arrives at the input. Check the wiring and the GPIO number in dlbus.gpio, and that the transistor side of the optocoupler has a pull-up (gpioTermination: pullup or an external resistor). --debug logs every GPIO edge; none at all means the input is silent.

The web page shows Signal, no valid frames The bus works, but the frames do not fit datalogger.type — another controller type, or a UVR42 set up as uvr31. --debug logs why each frame was rejected.

The web page shows Signal not decoded Edges arrive at the input, but no frame comes out of them. If the bit rate is marked, the decoder follows the wrong clock: after a burst of noise it can lock on twice the 50 Hz of a UVR42 or UVR31 and decode nothing. The decoder leaves such a clock on its own, and a watchdog resets the decoder whenever edges arrive without a frame for 30 s. The first reset is logged as a warning with the decoder's state, further ones at debug level until a frame comes again, so an input that never decodes does not fill the log; all of them are counted in bus.decoderResets. bitClock: 50 avoids clock recovery altogether. If the state persists with the right bit rate, the signal is too distorted: check the optocoupler side of the wiring and dropped edges in the error count.

Before tadl 3.1.0 the page showed No signal in this case and there was no watchdog. Up to 3.0.3 the decoder could also stay on twice the bit rate for hours (bitRateHz near 100 in /health), and only systemctl reload tadl brought it back; 3.0.4 leaves such a clock on its own.

A temperature shows sensor fault and /data leaves it out The controller reports a value outside −50 … 300 °C, usually a sensor that is not connected or broken. The log names the sensor and its value, at most once a minute.

A request is refused with 401 The token is wrong or not in the token file, or the token file changed and tadl was not reloaded. sudo tadl token list shows the names; the log has a warning with the caller's address.

Service is failed immediately after start A configuration error; the process exits with code 78, which the unit does not restart, and prints the reason on stdout: Failed to load config file (YAML could not be parsed — durations such as publishInterval must be Go duration strings like 60s, not plain numbers, and the token file must not hold one hash under two names) or config validation failed, which names the key — for example an allowedIPs or blockedIPs entry that is no address or network, or an mqtt.connection that cannot be parsed. Check with journalctl -u tadl -n 20.


Backup & restore

# Backup
sudo tar czf /tmp/tadl-backup.tar.gz /opt/tadl

# Restore
sudo tar xzf /tmp/tadl-backup.tar.gz -C /
sudo systemctl restart tadl

tar keeps owners and modes. tadl keeps no data of its own; the backup holds the binary, the configuration, the token file and the certificate.


Releases

Every release on the releases page carries archives for all Raspberry Pi architectures with the binary, the controller emulator dlbussim, config/config.yaml, contrib/tadl.service, README.md and LICENSE, plus a checksums.txt and a changelog. Versions follow semantic versioning; a breaking change of the API, the telegram or the configuration raises the major version.

tadl --version reports the release a binary was built from. A local build reports something like 3.0.2-3-g0c13781-dirty instead, which is how the two are told apart on a device.

Building from source needs Go and make: clone the repository and run make help for the targets; make test runs the tests (Linux only), make lint gofmt, go fix, go mod tidy, vet, golangci-lint and govulncheck, as CI does; CLAUDE.md describes the architecture, the tests and the release process.

Upgrading to 3.2.0

tadl checks the config and the token file more strictly. A file that 3.1 accepted can now stop the start with exit code 78, which systemd does not restart, so check before you replace the binary:

  • webserver.allowedIPs and blockedIPs: every entry must be an address or a network. Before, a bad entry was only logged and skipped — a typo in blockedIPs blocked nothing.
  • mqtt.connection must be a URL tadl can parse. Before, such a URL let tadl run without MQTT.
  • The token file must not hold the same hash under two names. tadl token remove refuses such a file too, so delete one of the two lines with sudoedit /opt/tadl/etc/token-hashes.

After the restart, journalctl -u tadl names the key that was refused. A warning "Token file is not protected" means the service user could change the token file or a directory above it: see Tokens.

Upgrading to 3.0.0

The API key is replaced by bearer tokens, HTTPS is TLS 1.3 only, and the unit lives in the repository:

  1. Make /opt/tadl root-owned as in the Quick start.
  2. Put the new binary next to the old one as bin/tadl.new, and a copy of the config as etc/config.yaml.new with apiKey: … replaced by tokenFile: token-hashes. The running 2.x is not touched by either.
  3. Create the tokens with the new binary: sudo /opt/tadl/bin/tadl.new token add browser -config /opt/tadl/etc/config.yaml.new. This only writes the token file; it opens no GPIO.
  4. Move tadl.new and config.yaml.new into place, install contrib/tadl.service, then sudo systemctl daemon-reload && sudo systemctl restart tadl. A config that still has apiKey is refused with a message that says so.
  5. Every caller sends Authorization: Bearer <token>; X-API-Key is no longer accepted. The web page asks for the token once.
  6. A client limited to TLS 1.2 can no longer connect.

License

tadl is released under the MIT License - see LICENSE for the full text.

tadl is not affiliated with Technische Alternative. UVR42 and UVR31 are product names of Technische Alternative RT GmbH.

Third-party licenses

The source tree contains no third-party code, but a compiled binary statically links the modules below. Their terms apply to anyone distributing that binary, not to the sources here.

Module License
github.com/eclipse/paho.mqtt.golang EPL-2.0, dual with EDL-1.0
github.com/womat/golib/v2 MIT
github.com/womat/gpio MIT
github.com/womat/manchester MIT
github.com/womat/mqtt MIT
github.com/warthog618/go-gpiocdev MIT
gopkg.in/yaml.v3 MIT and Apache-2.0
github.com/gorilla/websocket BSD-3-Clause
golang.org/x/net, sync, sys BSD-3-Clause
Swagger UI build only (-tags swagger):
github.com/swaggo/swag, http-swagger, files MIT
github.com/go-openapi/*, go.yaml.in/yaml/v3 Apache-2.0
github.com/KyleBanks/depth MIT
golang.org/x/mod, tools BSD-3-Clause

All of these are permissive except the Eclipse Paho MQTT client, which is weak copyleft at file level: if you hand out a built binary, the source of the EPL-covered parts has to remain available (it is, at https://github.com/eclipse-paho/paho.mqtt.golang). Paho is dual-licensed, so the BSD-style EDL-1.0 may be chosen instead. Neither obliges tadl itself to change its license.

About

Read the DL-Bus of a Technische Alternative UVR42 or UVR31 heating controller on a Raspberry Pi – live web page with temperature trends, MQTT and REST API. Wired through an optocoupler, no setting for bit rate or polarity. Single Go binary, no cloud.

Topics

Resources

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages