Skip to content
pansenPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

51 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

heat

This repository is a cleaned copy of an internal repository, to have it publicly accessible. Maybe it can be helpful for somebody.

Read (and eventually control) a Remeha Tensio C air-water heat pump from a Raspberry Pi over WLAN. The M5Stack ATOM Lite sits inside the heat-pump controller and acts as a transparent RS-485-over-TCP bridge — nothing more. All Modbus logic, decoding and metrics live on the Pi.

┌───────────────┐  RS-485  ┌────────────────────┐   WLAN   ┌───────────────┐
│  Remeha       │──X/Y────▶│  ATOM Lite + RS485 │──TCP────▶│  Raspberry Pi │
│  Tensio C     │◀─── ── ──│  (Rust firmware)   │◀────────│  pymodbus +   │
│  (Modbus RTU) │          │  serial↔TCP bridge │          │  Prometheus   │
└───────────────┘          └────────────────────┘          └───────────────┘

Grafana Dashboard Preview

image

Why this split

Rationale in doc/example.com.md, section "how much of a tradeoff do you see if i'll work in python": the ATOM has tight RAM, a fragile GC-in-the-loop for MicroPython, and no debugger story for high-level languages. Modbus decoding of Midea-clone registers is fiddly. Anything that has to be iterated on — register maps, parsing, metrics, alerts, control loops — belongs on the Pi where a crashed process just restarts and there is a real toolchain. The ATOM only has to ferry bytes.

Language choice: Rust

Constraints from CLAUDE.md: no C++, no MicroPython. Between the two remaining options for the ESP32-PICO-D4:

  • TinyGo does not have first-class Wi-Fi on the built-in ESP32 radio (it targets external NINA co-processors). Not viable for a WLAN bridge.
  • Rust via esp-idf-svc binds the full ESP-IDF C SDK: real Wi-Fi stack, TCP, UART, NVS, OTA, std::net, std::thread. Mature and actively maintained by the esp-rs project.

There is no ESP-side "heat-pump library" worth fighting for — Midea RTU decoding is a Pi-side concern. What matters on the ATOM is a stable network stack; Rust wins there.

Hardware

Pin mapping baked into firmware/src/main.rs:

ATOM Lite GPIO Function RS-485 Base terminal
GPIO 19 UART1 TX A / B via SP3485EE
GPIO 22 UART1 RX A / B via SP3485EE
— GND G

Terminal block on the base: A = data non-inverting, B = data inverting, G = ground, DC 12 V = optional supply (onboard step-down to 5 V feeds the ATOM Lite; USB-C is the alternative).

Direction switching is automatic on the SP3485EE — no DE/RE GPIO needed.

Wiring to the Remeha Tensio C

Tap the internal Modbus (controller ↔ unit) at the X/Y (D1/D2) terminals. The H1/H2 terminals are a separate external BMS Modbus — not this one.

Heat pump (internal bus) ATOMIC RS-485 Base
Y (D2) A
X (D1) B
E G

Full step-by-step guide (safety, power, first-light check): WIRING.md.

Safety notes are in doc/example.com.md under "how safe is it to connect to the controller of the heatpump". Summary: kill the breakers before wiring, prefer USB-C power on the ATOM over tapping the heat-pump 12 V rail, and if you want galvanic isolation swap the standard base for the Isolated RS485 Unit.

Repo layout

heat/
├── Makefile              # build / flash / monitor / read / read.raw / logs / erase / clean
├── README.md
├── CLAUDE.md             # constraints for AI-assisted edits
├── WIRING.md             # step-by-step wiring guide to the Remeha Tensio C
├── doc/                  # research notes (German + English, source material)
├── firmware/             # Rust firmware for the ATOM Lite
│   ├── .cargo/config.toml
│   ├── build.rs
│   ├── Cargo.toml
│   ├── cfg.toml.example  # copy → cfg.toml, fill in Wi-Fi creds + api_key
│   ├── rust-toolchain.toml
│   ├── sdkconfig.defaults
│   └── src/
│       ├── main.rs       # esp-idf wiring: UART, Wi-Fi, TCP bridge, boot-time bus probe
│       ├── lib.rs         # pure protocol logic (frame reassembly, read-only allowlist, auth) — host-testable
│       └── netlog.rs      # TCP log mirror (port 8898), see "Remote logs" below
└── read/                 # Python passive Modbus RTU sniffer, run from the Pi
    ├── main.py            # CLI entrypoint (`uv run main.py`, see "Passive sniffing" below)
    ├── sniffer.py         # socket handling, per-frame formatting, running stats, exit summary
    ├── modbus.py          # pure protocol: CRC-16, RTU frame decoding (offline self-test)
    └── .env               # api_key, hostname — gitignored, copy from .env.example

Prerequisites

  • macOS or Linux host
  • rustup (the esp-toolchain gets installed alongside via espup)
  • Python 3 (only needed transitively by esptool — espflash handles the flashing itself)
  • USB-C cable to the ATOM Lite

Setup

make setup                # installs espup, ldproxy, espflash, esp toolchain
cp firmware/cfg.toml.example firmware/cfg.toml
$EDITOR firmware/cfg.toml # fill in wifi_ssid / wifi_pass / api_key

make build and make flash source ~/export-esp.sh (the toolchain env espup writes) automatically, so no manual step is needed. Only if you run cargo directly do you need it on your shell — put source ~/export-esp.sh in your ~/.zshrc / ~/.bashrc once and forget about it.

Flash + run

make flash                # compiles + writes + attaches monitor

Under the hood: cargo run --release, which triggers the runner declared in firmware/.cargo/config.toml — espflash flash --monitor.

Serial baud on the monitor side is 115200 (ESP-IDF default), separate from the RS-485 baud (9600). On boot the log stream shows:

uart1 open at 9600 baud (tx=GPIO19, rx=GPIO22)
rs485: probing bus for 4s at 9600 baud (passive, no tx)…
rs485: OK — 47 Modbus frame(s), 512 byte(s), 98% of stream framed in ~4s. Polarity, wiring and baud look correct.
wifi connecting to <ssid>
wifi up: ip=192.168.x.y gw=... mask=...
bridge listening on 0.0.0.0:8899 (read_only=true)

Note the IP — you'll need it Pi-side.

RS-485 wiring self-test

Before touching the Pi, the firmware spends bus_probe_secs (default 4, cfg.toml) passively listening on the RS-485 bus and reports what it sees. It never transmits, so it's safe to run against a live heat pump. It works because the pump's own wired controller polls the bus continuously — a correctly tapped X/Y (D1/D2) pair already carries frames. So right after make flash with the cables connected, the log tells you which of three states you're in:

Log line Meaning
rs485: OK — N Modbus frame(s) … look correct Wiring, A/B polarity and baud are all right. Proceed to the Pi.
rs485: line is live … but only N valid … frame(s) Bytes arrive but don't decode → X/Y swapped (swap D1↔D2) or wrong rs485_baud.
rs485: no bytes received … Nothing on the line → check A/B and G, and that the controller is powered/polling.

Set bus_probe_secs = 0 to skip the probe once you're past wiring.

The default PORT in the Makefile globs /dev/cu.usbserial-* and friends. Override for a specific device:

make flash PORT=/dev/cu.usbserial-0001

Authentication

Every TCP connection must open with a single line preamble before any Modbus bytes:

Authorization: Bearer <api_key>\r\n

The key is set in firmware/cfg.toml (api_key = "..."). The firmware refuses to boot with an empty key. On mismatch or 5 s timeout the ATOM replies 401 unauthorized\r\n and closes the socket. Compare is constant-time.

This is deliberately not full HTTP — just enough to keep anything on the LAN that stumbles onto port 8899 from talking to the heat pump. Treat the key as a shared secret and rotate it by re-flashing (make flash).

Remote logs (no USB required)

Every info!/warn!/error! the firmware logs to the UART console is also mirrored to a second TCP port (log_port in cfg.toml, default 8898), gated by the same bearer key as the bridge port. This is the intended way to read logs once the ATOM is wired into the heat pump: plugging in USB to run espflash monitor resets the ESP on attach, which drops the RS-485 bus mid-poll — something you don't want on a live controller.

{ printf 'Authorization: Bearer <api_key>\r\n'; cat; } | nc <atom-host-or-ip> 8898

The cat after the printf keeps stdin open so the shell doesn't close the socket right after sending the preamble — omit it and you'll get disconnected before any log lines arrive. As with the bridge port, a missing/wrong preamble gets 401 unauthorized\r\n and the socket is closed. Multiple nc readers can be attached at once; each gets every line. Set log_port = 0 in cfg.toml to disable the server entirely.

Passive sniffing from the Pi (recommended first step)

Before reaching for pymodbus, try the sniffer in read/. It authenticates once with the bearer preamble and then only ever recv()s — it never transmits on the bus, so it's safe to run against the live heat pump. It decodes the RTU frames the pump's own controller is already exchanging, which is often enough to find slave IDs and register addresses without writing any Modbus client code.

make read                                # decoded frames, run until Ctrl-C
make read.raw                            # also hex-dump every raw chunk
cd read && uv run main.py --duration 60  # auto-stop after 60s

Config lives in read/.env (gitignored — copy from .env.example): api_key (same key as firmware/cfg.toml) and hostname (the ATOM's address).

On exit (Ctrl-C, bridge disconnect, or --duration elapsing) it prints a summary: the slave IDs seen, the (address, count) pairs read-polled per slave, and the latest decoded register value per (slave, address) — a quick map of what the controller is already reading, with zero risk to the bus. See read/README.md for the full CLI, live output format, and file breakdown.

Two masters on one bus. The internal Modbus bus tapped here already has an active master — the heat pump's own wired controller, polling continuously (that's what the boot-time wiring self-test relies on). Passive sniffing above is the safe default. Actively polling through the bridge, e.g. with the pymodbus shim below, puts a second master on the same bus: collisions and timeouts with the controller's own polling are possible. If you do poll actively, do it cautiously — short reads, be prepared to handle collisions/timeouts, and watch the pump's display for communication faults (it has raised E2 Kom Fehler under bus disturbance before).

Talking to the bridge from the Pi

pymodbus cannot inject the bearer preamble itself, so run a tiny local shim that opens the ATOM socket, sends the header, then relays bytes to a localhost port that pymodbus points at:

# atom_auth_shim.py — run once, then point pymodbus at 127.0.0.1:1502
import socket
import socketserver
import threading

ATOM_HOST = "192.168.x.y"
ATOM_PORT = 8899
API_KEY   = "the-key-from-cfg-toml"
LOCAL     = ("127.0.0.1", 1502)

def pipe(a: socket.socket, b: socket.socket) -> None:
    try:
        while chunk := a.recv(4096):
            b.sendall(chunk)
    finally:
        for s in (a, b):
            try: s.shutdown(socket.SHUT_RDWR)
            except OSError: pass

class Handler(socketserver.BaseRequestHandler):
    def handle(self) -> None:
        upstream = socket.create_connection((ATOM_HOST, ATOM_PORT))
        upstream.sendall(f"Authorization: Bearer {API_KEY}\r\n".encode())
        threading.Thread(target=pipe, args=(self.request, upstream), daemon=True).start()
        pipe(upstream, self.request)

if __name__ == "__main__":
    with socketserver.ThreadingTCPServer(LOCAL, Handler) as srv:
        srv.serve_forever()

Then the Modbus client stays clean:

from pymodbus.client import ModbusTcpClient
from pymodbus.framer import FramerType

client = ModbusTcpClient("127.0.0.1", port=1502, framer=FramerType.RTU)
client.connect()
# slave id and register map are Remeha/Midea-specific — see references below
rr = client.read_holding_registers(address=0x0000, count=1, slave=1)
print(rr.registers if not rr.isError() else rr)

Sanity-check the auth path from the shell with nc:

printf 'Authorization: Bearer wrong\r\n' | nc 192.168.x.y 8899
# → 401 unauthorized

Full architecture (Prometheus exporter, register decoding) is sketched in doc/example.com.md — that part lives outside this repo (Docker/K8s deployment on the Pi).

Tests

Pure protocol code (bearer auth, Modbus function-code allowlist, exception frame + CRC) lives in firmware/src/lib.rs and is host-runnable:

make test

That invokes cargo test --lib --target $(HOST) — the bin target and all esp-idf-* deps stay out of the picture (they are gated behind cfg(target_os = "espidf") in Cargo.toml). Add coverage next to the existing #[cfg(test)] mod tests in lib.rs.

Development notes

  • Frame reassembly, then read-only enforcement. TCP does not preserve Modbus frame boundaries — one read() can split a frame or coalesce several — so the firmware first reassembles the client's byte stream into whole RTU request frames (FrameAssembler in lib.rs, length-delimited by function code and CRC-checked) before doing anything with them. This is what makes the read-only guard sound: classifying per raw chunk let a write ride inside the same segment as a read and slip through. With read_only = true (the default in cfg.toml) only frames whose function code is a read op (0x01–0x04, 0x07, 0x0B, 0x0C, 0x11, 0x14) are forwarded. Writes (0x05, 0x06, 0x0F, 0x10, 0x16, 0x17) are answered with a synthesized Modbus RTU exception [slave, fc|0x80, 0x01, crc_lo, crc_hi] (0x01 = ILLEGAL FUNCTION); the request never reaches the heat pump. On the Pi side this surfaces as a ModbusIOException in pymodbus with a proper exception code, not a mystery timeout. Defense-in-depth for the "we don't want to brick the heat pump" concern from doc/example.com.md § "can i have the atom thing to act strictly read-only". The Pi side should still avoid write calls — belt and braces.
  • Modbus RTU timing. RTU frames end on 3.5 char times of silence. Because the assembler forwards each reassembled frame to the UART in a single uart.write(), a frame is never smeared across a mid-frame TCP gap. Bytes that fail CRC or carry an unknown function code are dropped (logged) rather than dribbled onto the bus — the master times out and retries. A Dropped/CrcMismatch in the log points at a wiring or baud problem, not a transient.
  • Single client at a time. serve_bridge accepts serially. Modbus is master/slave — you want exactly one master anyway. Second connection waits until the first disconnects.
  • OTA. Not wired up yet. First flashes are via USB. Once the register map is stable and the ATOM is installed inside the heat pump, add esp_ota — trivial with esp-idf-svc.

Troubleshooting

Symptom Likely cause / fix
espup not found make setup again, or cargo install espup --locked
ldproxy not found during build cargo install ldproxy --locked
xtensa-esp32-espidf target not installed Build via make build/make flash (they source it), or source ~/export-esp.sh for direct cargo
Serial port permission denied macOS: ls /dev/cu.*; Linux: add user to dialout
wifi_ssid is empty You forgot cp cfg.toml.example cfg.toml and filling it in
api_key is empty Set api_key in cfg.toml — firmware refuses to boot without one
Socket returns 401 unauthorized Preamble line missing or key wrong; the Pi shim isn't running
Bridge connects, no bytes Check A/B are not swapped and G is wired; verify baud in cfg.toml
ModbusIOException illegal function on write read_only = true in cfg.toml — set to false if intentional, or use a read FC
Boots and hangs on Wi-Fi 2.4 GHz only. ATOM Lite has no 5 GHz radio

References

M5Stack hardware

Espressif toolchain

Rust on ESP32

Modbus / Pi-side stack

Remeha / Midea heat-pump reverse engineering

Internal notes

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages