Architecture
HeatSync is a single-binary ESP32 sketch. The code is split into themed
modules under src/ so each file has a small surface and there’s an
obvious place to grow.
Module map
Section titled “Module map”samsung_controller.ino ← entry point, holds the loop()config.h ← feature flags + hardware pin defines
src/├── nasa/ ← Vendored upstream parser (MIT, from omerfaruk-aran)├── bus/ ← RS485 driver + protocol target + sniffer│ ├── bus.cpp/.h TX/RX, statistics│ ├── bus_sniffer.cpp/.h ring of recent (source, msgNo) tuples│ └── serial_target.cpp/.h base implementation of MessageTarget├── ha/ ← MQTT + HA discovery + live state│ ├── ha_bridge.cpp/.h Inherits SerialBusTarget; publishes to MQTT│ ├── mqtt_client.cpp/.h thin wrapper over PubSubClient│ ├── live_state.cpp/.h per-address mirror of latest values (for /api/live)│ └── device_names.cpp/.h friendly-name overrides├── ble/ ← Passive BLE scan + room-temp injection│ ├── ble_sensor.cpp/.h NimBLE scanner (gated on HEATSYNC_BLE_ENABLED)│ └── room_temp_injector.cpp/.h pushes BLE-sourced temps onto the bus├── net/ ← WiFi + captive portal│ ├── wifi_setup.cpp/.h NTP + IPv6 + mDNS init│ └── captive_portal.cpp/.h first-boot wizard├── web/ ← HTTP server + UI assets│ ├── web_server.cpp/.h routes for all HTML pages + REST API + FSV poller│ ├── ui_styles.cpp/.h shared CSS embedded as PROGMEM│ └── ui_demo.cpp demo-mode shim (?demo=1)├── hw/ ← Hardware drivers│ ├── button.cpp/.h gesture detection on the Atom's onboard button│ ├── status_led.cpp/.h WS2812 driver + LED state machine│ └── event_log.cpp/.h 80-entry in-RAM ring for /api/events└── core/ └── persistence.cpp/.h NVS load/save for HvacConfigData flow — read path
Section titled “Data flow — read path”F1/F2 ──[UART2 9600 8E1]──→ bus.cpp:loopBus() │ ▼ process_data() in src/nasa/protocol.cpp │ MessageSets dispatched to MessageTarget │ ▼ HaBridge::set_*() (inherits SerialBusTarget) │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ LiveDeviceState MQTT publish publishField(addr, key) (in-RAM mirror) (HA discovery) │ ▼ /api/live JSON ← consumed by dashboard polling every 2sData flow — write path
Section titled “Data flow — write path”Web UI / HA command topic │ ▼handleApi…() in web_server.cpp │ fills a ProtocolRequest, calls publish_request() ▼NasaProtocol::publish_request() │ enqueues into outgoing_queue_ │ │ ┌── tick every 200 ms from loopBus() ▼ ▼NasaProtocol::protocol_update() │ builds a Packet (DataType::Request), calls publish_data() ▼HaBridge::publish_data() (overrides SerialBusTarget) │ ▼bus.cpp:transmitBytes() — waits for bus quiet, drives UART2 TXHow features land
Section titled “How features land”The codebase’s growth pattern is consistent — each new feature follows the same shape:
- Capture data in
ha_bridge.cpp. Add a case to theset_custom_sensorswitch (or a dedicated setter override) for the register, plus a field inLiveDeviceStateif you want it persisted. - Expose to HA by adding a
sensor(...)orbsensor(...)line inmaybePublishDiscovery(). The discovery payload tells HA how to render it (device class, unit, state class). - Expose to the dashboard by adding a row inside the relevant
block function (
dhwBlock,heatingBlock,outdoorBlock) inweb_server.cppand pulling the field from/api/live. - Add controls by writing a
handleApiXfunction inweb_server.cpp(auth + writes-enabled + targets the indoor unit address), registering the route, and adding a click handler in the dashboard JS that POSTs to it.
The schedule, DHW power, target stepper, mode pickers, water-law offset and fault detector all follow this pattern.
Threads & timing
Section titled “Threads & timing”There’s no FreeRTOS task per module — HeatSync runs everything from the
Arduino loop() on the single core. Each subsystem’s loop*() call
is non-blocking and ticks once per loop() iteration.
Background ticks with their own scheduling:
| Tick | Period | Owner |
|---|---|---|
| Bus RX / NASA decoder | every loop iteration | loopBus() |
| NASA outgoing-queue drain | 200 ms | loopBus() |
HTTP server handleClient() | every loop iteration | loopWebServer() |
| FSV poller (one read per tick) | 250 ms | loopWebServer() |
| DHW schedule decision | 60 s | loopWebServer() |
| MQTT publish/reconnect | every loop iteration (PubSubClient handles) | mqtt::loop() |
| LED state | every loop iteration | updateStatusLed() |
| Button gestures | every loop iteration | updateButton() |
This means latency-sensitive things (button press, bus RX) get serviced within ~milliseconds; lower-priority things (FSV poll, schedule decision) are batched by their tick interval to keep the loop fast.
Memory layout
Section titled “Memory layout”- Flash (PROGMEM): firmware binary + all HTML/CSS/JS strings.
Currently ~77% of 1.97 MB on the OTA-friendly
min_spiffspartition scheme. - DRAM (heap): WiFi/lwIP stack (~50 KB), web server (~10 KB), live state map, sniffer ring (~8 KB for 256 entries). Free heap typically 175 KB+ at steady state since dropping the optional BLE module.
- NVS:
HvacConfigstruct — credentials, MQTT settings, schedule parameters. ~1 KB.
What’s vendored vs. ours
Section titled “What’s vendored vs. ours”The contents of src/nasa/ are imported from
omerfaruk-aran/esphome_samsung_hvac_bus
under MIT — see src/nasa/UPSTREAM.md for the import diff. Two small
HeatSync-specific extensions are flagged inline in that source:
ProtocolRequest::room_temp and ProtocolRequest::water_law_offset.
Everything else under src/ is HeatSync’s own code.