Skip to content

Component Internals

Developer reference for contributors changing the radish ESPHome external component under components/radish/. Operators setting up a device should start with the ESPHome Component guide.

The stack is a UART-facing shell (radish.cpp), a protocol/controller core (ct_controller.cpp), and two services (ct_services_autonet_client.cpp, ct_services_subordinate.cpp).

Architecture

RS-485 UART RX
      │
      ▼
 RadishComponent ──buffer flush──► CtController::on_raw_chunk
      │                                 │
      │                                 ├── parse frames (codec)
      │                                 ├── AutoNetClient  (dataflow / config)
      │                                 ├── SubordinateService (app msgs 0x01..0xDA)
      │                                 └── TX queue / R2R / Token Offer / ACKs
      │                                 │
      │◄──────── TX bytes ──────────────┘
      │
      └── optional MQTT raw hex ──► mqtt_topic (default radish/rs485/raw)

CtController::tick() advances AutoNet slot delays, keepalive, and pending Token Offer windows.

File layout

File Role
__init__.py ESPHome schema / codegen
radish.h / radish.cpp Component + UARTDevice: buffer, MQTT forward, UART TX
ct_types.h Frames, identity, AutoNet config, queue, counters
ct_frame_codec.* Encode/decode, checksum, sliding resync
ct_controller.* Parse, dispatch, queue, ACK / R2R / Token Offer
ct_services_autonet_client.* AutoNet join / address lifecycle
ct_services_subordinate.* App handlers + shared-data persistence

Not implemented (yet)

  • Custom responses for most application message IDs (routed to SubordinateService, then ignored)
  • Active interrogation other than Get Configuration (0x01), Get Status (0x02), Get Sensor Data (0x07), and Get Identification Data (0x0E)

Application queries (configuration / status / sensor / identification)

CtController::request_configuration / request_status / request_sensor_data / request_identification enqueue one empty application request (0x01, 0x02, 0x07, or 0x0E) when the local address is non-zero. They refuse node type 0 and a full queue. The frame is addressed to the coordinator (255) with send method 2 and send parameters equal to the targeted node type (source index 0). It is not transmitted until the existing Token Offer / R2R path drains the queue. A successful enqueue records pending_app_query_ (kind, request/response message types, target node type). Only one pending query is kept at a time.

When a locally addressed, non-dataflow response arrives with send method 2, the expected response message type (0x81, 0x82, 0x87, or 0x8E), and send-parameters low byte equal to the pending node type, the controller encodes the frame into ControllerStepResult::app_query_response_bytes (with app_query_kind) and clears the pending flag. RadishComponent publishes that hex on the single app_query_mqtt_topic (default radish/app_query). Relinquish / AutoNet disable also clears the pending flag.

RadishComponent registers one Home Assistant service, get_app_query, with string kind and node_type (int32_t, validated to 1..255) via api::CustomAPIDevice. Canonical kind values are config, status, sensor, and id (case-insensitive; ordered by request message type). Aliases: configuration → config; sensor_data → sensor; identification / ident → id. The component's codegen sets USE_API_USER_DEFINED_ACTIONS and USE_API_CUSTOM_SERVICES; custom_services: true on the api: block remains the documented YAML counterpart. Host tooling: request_ct_query.py [--kind config|status|sensor|id] awaits the MQTT hex, parses with Frame, and exits.

Runtime behavior

Dispatch

Incoming frames go to exactly one path (CTRL-DISPATCH-001):

  • dataflow / configuration → AutoNetClient
  • application IDs 0x01..0xDA (excluding R2R) → SubordinateService
  • non-dataflow ACK handling → CtController directly

Raw MQTT forwarding (CTRL-RAW-001)

When enable_raw_mqtt_forwarding is true, UART chunks that reach the controller are published as uppercase hex on mqtt_topic, separated by hex_delimiter. A trailing delimiter is appended only when the delimiter is exactly " " (Python listener compatibility).

Queue and arbitration (CTRL-QUEUE-*)

  • Bounded TX queue (std::deque<QueuedTx>, default max depth 16); overflow increments queue_dropped and rejects enqueue
  • Entries tagged by source (Controller, AutoNetClient)
  • Response types (message_type & 0x80) prioritized when draining
  • Some AutoNet outputs send immediately when possible (NodeDiscoveryResponse, SetAddressResponse, AddressConfirmationResponse); otherwise enqueued

Local non-dataflow ACK

For locally addressed, non-dataflow frames:

  • GetNodeIdRequest (0x7B) ACKed before service handling
  • Other non-R2R / non-AddressConfirmationPush messages ACKed after service handling
  • ACK payload: code 0x06 + local MAC + local session ID

R2R (CTRL-R2R-*)

Locally addressed R2R: empty queue → R2R ACK; non-empty → send next queued frame instead. Non-addressed R2R ignored for TX decisions.

Token Offer (CTRL-TOB-*)

On TokenOffer (0x77) for subnet 0x03 or broadcast subnet, if the queue has work and this node has not already answered this cycle:

  • schedule delayed TokenOfferResponse (0xF7) using the same Slot Delay generator as Node Discovery (CT-485 100–2500 ms pseudorandom, or optional YAML slot_delay_ms override)
  • cancel if any new bus chunk arrives before the delay elapses
  • at most one response per dataflow cycle; the cycle resets on AddressConfirmationPush on subnet 0x03

Slot Delay

CT-485 defines a Slot Delay as a pseudorandom delay from 100 ms to 2500 ms (1 ms steps, uniform over the range). The PRNG is externally seeded (device MAC by default, or autonet_deterministic_seed) so identical firmware on different devices does not share delay sequences. The same generator is used for Node Discovery, Token Offer, and broadcast Address Confirmation arbitration. Optional YAML slot_delay_ms forces a fixed value in that range instead of deriving one.

Option Used by Purpose
(derived) CtController + AutoNetClient Spec Slot Delay for discovery / Token Offer / broadcast Address Confirmation
slot_delay_ms same Optional fixed override (100–2500 ms)
autonet_deterministic_seed AutoNetClient Optional external PRNG seed (tests / reproducibility)

User-facing YAML tables: ESPHome Component guide.

AutoNet client

State machine (AUTONET-STATE-001)

UNADDRESSED
    │  NodeDiscoveryRequest match
    ▼
AWAITING_SLOT_DELAY_FOR_DISCOVERY_RESPONSE
    │  delay elapsed → enqueue 0xF9          │ bus activity → cancel
    ▼                                        └──────────────► UNADDRESSED
AWAITING_SET_ADDRESS
    │  valid SetAddress (MAC/session + write)
    ▼
ADDRESSED_ACTIVE
    │  AddrConf Broadcast miss 120s / Expected Node Position mismatch /
    │  SetAddress 0/0 / join disabled
    ▼
RELINQUISH_PENDING ──► UNADDRESSED (assignment cleared)

Behavior notes

  • Discovery (0x79): Slot Delay gated (AUTONET-DISC-001); any bus traffic during the window cancels and returns to UNADDRESSED (AUTONET-DISC-002)
  • Set Address (0x7A): accept only if write-enabled and MAC/session match (AUTONET-SETADDR-001); 0/0 forces relinquish (AUTONET-SETADDR-002)
  • Get Node ID (0x7B): addressed request → 0xFB with node type, MAC, session (AUTONET-NODEID-001)
  • Address Confirmation (0x76): direct → immediate 0xF6 (AUTONET-ADDRCONF-001); broadcast → Slot Delay arbitration, cancel on bus activity (AUTONET-ADDRCONF-002)
  • Address Confirmation Broadcast missing for 120 s, or Expected Node Position mismatch while addressed on subnet 0x03 → relinquish address/subnet and wait to be re-addressed (AUTONET-KEEPALIVE-001); timeout is CT_AUTONET_KEEPALIVE_TIMEOUT_MS (not YAML-configurable)
  • Disabling AutoNet (YAML or join switch) forces relinquish and clears assignment
  • Effective MAC is fixed at runtime, 8 bytes, prefix 00:00:09 (AUTONET-MAC-001); explicit local_mac_address wins over device-identity derivation
  • Address/subnet are not YAML fields — assigned only via AutoNet SetAddressRequest

Subordinate service

Handles locally addressed non-dataflow application space 0x01..0xDA (excluding R2R).

Request Response Notes
SetNetworkNodeList (0x14) 0x94 Echo-style
NetworkSharedDataSector (0x7D) 0xFD Read/write via payload byte 0 bit 7; ESPHome preferences persistence

Shared-data sector mapping by requested node type:

Sector Node types
0 1 (thermostat), 21 (zone controller)
1 2, 3
2 4, 5, 12

Sector 0 gives node type 21 overwrite priority over node type 1: if a valid sector image already holds type 21, a write from type 1 is rejected so a zone controller’s shared data is not clobbered by a thermostat write.

Observability

ESPHome logs (component tag radish / AutoNet client logs): boot config dump (topic, timeouts, identity, AutoNet on/off), join enable/disable, discovery slot delays, address accept/relinquish, keepalive failures, invalid MAC warnings, MQTT publish failures.

Controller counters (ControllerCounters in ct_types.h): raw_chunks_seen, raw_bytes_seen, frames_valid, frames_checksum_failed, frames_truncated, frames_filtered, queue_dropped, tx_attempted, tx_success, tx_failed. Useful when extending firmware or debugging from a debugger; not all are exposed as HA sensors today.

MQTT: with forwarding enabled, watch radish/rs485/raw (or your topic) via the Python decoder while testing join.

Contract tests

From the repo root:

python -m unittest tests.test_controller_behavior_contract \
  tests.test_autonet_client_state_machine \
  tests.test_subordinate_service_routing_contract -v
Test module Covers
test_controller_behavior_contract.py Raw hex formatting, replay parse parity with controller walk
test_autonet_client_state_machine.py AutoNet state transitions
test_subordinate_service_routing_contract.py Subordinate routing boundaries / handlers

These are Python models of the C++ contracts, not on-device ESPHome tests.

Behavior rule IDs → spec

IDs are referenced in implementation notes and tests. Full Networking Spec: CT-485 Networking Specification (archive index).

Rule ID Summary Spec §§
CTRL-RAW-001 Forward UART chunks as spaced uppercase hex when enabled —
CTRL-DISPATCH-001 One service path per frame class —
CTRL-QUEUE-001–003 Bounded queue, source tags, prioritize responses, drop on overflow 7.5, 7.6.1, 7.9
CTRL-R2R-001–003 Addressed R2R ACK vs dequeue; ignore non-addressed 7.4.1, 7.5.3, 7.8
CTRL-TOB-001–004 Token Offer slot delay, cancel on bus, once per cycle (reset on AddrConf) 7.5.4, 11.1, 11.13
AUTONET-STATE-001 Explicit client states 11.12
AUTONET-DISC-001–002 Discovery slot delay; cancel on bus activity 11.1, 11.12
AUTONET-SETADDR-001–002 MAC/session/write gate; 0/0 relinquish 11.12
AUTONET-NODEID-001 Addressed Get Node ID → 0xFB 11.12
AUTONET-KEEPALIVE-001 No Address Confirmation Broadcast for 120 s, or Expected Node Position mismatch → relinquish and re-enter 9.1.2
AUTONET-ADDRCONF-001–002 Direct vs broadcast Address Confirmation 7.4.2, 9.1.2, 11.1
AUTONET-MAC-001 Stable 8-byte 00:00:09… MAC 11.12