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 →
CtControllerdirectly
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 depth16); overflow incrementsqueue_droppedand 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 YAMLslot_delay_msoverride) - cancel if any new bus chunk arrives before the delay elapses
- at most one response per dataflow cycle; the cycle resets on
AddressConfirmationPushon subnet0x03
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 toUNADDRESSED(AUTONET-DISC-002) - Set Address (
0x7A): accept only if write-enabled and MAC/session match (AUTONET-SETADDR-001);0/0forces relinquish (AUTONET-SETADDR-002) - Get Node ID (
0x7B): addressed request →0xFBwith node type, MAC, session (AUTONET-NODEID-001) - Address Confirmation (
0x76): direct → immediate0xF6(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 isCT_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); explicitlocal_mac_addresswins 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 |