Skip to content

XTREM Bus and Discovery

Robin Krämer edited this page Sep 29, 2026 · 2 revisions

This page covers xtrem::transport (the shared UDP socket) and xtrem::discovery (finding modules and assigning device IDs).

Why one socket for all modules

UDP modules don't each get their own socket. A module doesn't reply to the source port of the request; it sends to the port in its own register 0700h. So all traffic from every module arrives on one local port, and the host has to sort it by content. XtremBus binds that one socket and routes each incoming frame by its ID_O field, the sender's device ID.

flowchart LR
    M1[(XTREM id 01)] -- "UDP → :5555" --> S
    M2[(XTREM id 02)] -- "UDP → :5555" --> S
    subgraph Host
        S["XtremBus socket<br/>0.0.0.0:5555"] --> RX["receive task<br/>(common runtime)"]
        RX -- "ID_O = 01" --> A["XtremScale 01"]
        RX -- "ID_O = 02" --> B["XtremScale 02"]
        RX -- "every frame" --> D["events()<br/>(discovery)"]
    end
    A -. "try_send → :4444" .-> M1
    B -. "try_send → :4444" .-> M2
Loading

Opening the bus

use xtrem::transport::{XtremBus, XtremBusConfig};

let bus = XtremBus::open(XtremBusConfig {
    bind_addr: "0.0.0.0:5555".parse()?,
    broadcast_addr: "192.168.4.255:4444".parse()?,
    host_id: 0x00,
    verify_lrc: true,
    crlf: true,
})?;
Field Default Meaning
bind_addr 0.0.0.0:5555 local socket. The port must match the modules' register 0700h.
broadcast_addr 255.255.255.255:4444 where Destination::Broadcast goes: the subnet broadcast address plus the modules' listen port (register 0701h)
host_id 0x00 the ID_O this host puts in every frame it sends
verify_lrc true reject frames with a wrong LRC. Turn it off only if the modules have LRC checking off (0011h).
crlf true append CR+LF after ETX. The Wi-Fi/Ethernet module requires it.

The factory ports are also available as constants: DEFAULT_DEVICE_REMOTE_PORT (5555, register 0700h) and DEFAULT_DEVICE_LOCAL_PORT (4444, register 0701h). broadcast_addr_for(ip, prefix_len, port) computes a directed broadcast address, for example 192.168.4.17/24 → 192.168.4.255.

XtremBus::open binds a non-blocking socket with broadcast enabled, and spawns the receive task on common::get_async_runtime(). It returns an XtremBusHandle, which is cheap to Clone. The receive task stops when the last handle is dropped.

Why the bind address must be 0.0.0.0

This is a hard requirement, and it was confirmed on real hardware. The module never replies to the requester's unicast IP. It always sends to the broadcast address, even when it answers a unicast request. The kernel doesn't deliver those broadcasts to a socket bound to one specific LAN address, and neither side reports an error. The only way to receive replies is to bind 0.0.0.0:<port>.

XtremBus::open therefore returns an InvalidInput error for any bind IP other than 0.0.0.0 or loopback. Loopback is allowed because the test suite's fake module replies with real unicast.

Sending

Method Use from
handle.try_send(&frame, destination) a synchronous control loop. It never blocks, and returns WouldBlock if the socket's send buffer is full; retry on the next tick.
handle.send(&frame, destination).await async code, such as discovery

Destination is either Broadcast (the configured broadcast_addr; the ID_D field picks which module answers) or Unicast(addr) (one module's IP, once discovery has found it). Use unicast whenever you can: it keeps broadcast traffic off the subnet, and it avoids ambiguity when device IDs collide.

handle.read_frame(id, addr), write_frame(id, addr, data) and execute_frame(id, addr) build frames with ID_O = host_id already filled in.

Receiving

The receive task decodes every datagram, then:

  1. passes it to every handle.events() receiver, a tokio::sync::broadcast channel with a backlog of 256 frames. Discovery uses this because it doesn't know the device IDs yet.
  2. routes it to the handle.subscribe(device_id) receiver for its ID_O, if there is one: a tokio::sync::mpsc queue of 8 frames.

Each item is an Inbound { frame, from: SocketAddrV4, at: Instant }.

Routing rules:

  • One route per device ID. Calling subscribe again for the same ID replaces the earlier route, and the earlier receiver stops getting frames. So if two modules share an ID, they can't both be attached. Give them unique IDs first (see Assigning device IDs).
  • Routing ignores the source IP. Frames are routed by ID_O alone, even when the driver knows the module's unicast address.
  • A full queue drops the new frame, not the oldest one, and increments stats().dropped. The queue is filled with try_send, so the receive task never waits for a slow consumer. Drain the queue every tick (XtremScale::handle_response does this).
  • unsubscribe(device_id) removes the route.

Datagrams without an STX aren't XTREM traffic and are ignored silently. IPv6 datagrams are ignored.

Diagnosing a quiet bus: handle.stats()

BusStats counts what the receive task has seen:

Counter Rises when Points to
frames_received a frame decoded the link works
decode_errors a datagram had an STX but didn't decode wrong LRC setting (verify_lrc), or a mismatched protocol
unrouted a frame's ID_O has no subscriber a module with an unexpected ID, or a scale that was dropped
dropped a device queue was full a consumer that isn't draining its queue
io_errors recv_from failed socket or interface problems

If everything stays at 0, nothing is arriving at all. Check the bind port (0700h), check that the bind address is 0.0.0.0, and check that you're on the right network.

Discovery

use std::time::Duration;
use common::get_async_runtime;
use xtrem::discovery;

let probes = get_async_runtime().block_on(discovery::discover(&bus, Duration::from_secs(2)))?;
for p in &probes {
    println!("id {:02X} serial {} at {} sealed={:?} collision={}",
        p.device_id, p.serial, p.addr, p.sealed, p.id_collision);
}

discover(&handle, window) works like this:

  1. It broadcasts a read of 0000h (serial number) to ID_D = FF. It subscribes to events() first, so a fast answer isn't missed.
  2. It collects every serial-number response for window (DEFAULT_DISCOVERY_WINDOW is 2 s). Results are keyed by source IP, so two modules with the same device ID are still told apart.
  3. For each responder, it reads 0009h (sealing switch) and 0100h (device state) over unicast, with a timeout of DEFAULT_REQUEST_TIMEOUT (500 ms) each.
  4. It returns the probes sorted by serial number, so repeated sweeps give a stable order.

XtremProbe fields:

Field Meaning
device_id the ID_O the module answered with
serial register 0000h, the stable identity to key on
addr the source address of the reply. Use it for unicast.
sealed Some(true) if the sealing switch is locked, None if the follow-up read timed out
state Some(DeviceState) if the follow-up read succeeded
id_collision another responder has the same device_id

read_once(&handle, device_id, addr, register, timeout) reads one register over unicast and waits for the answer. It returns None on a timeout or an unparseable payload.

Assigning device IDs

Every XTREM ships with device ID 01. On a new install with several modules, they all collide, and only one of them can have an XtremScale on the bus. Give each one a unique ID once:

use xtrem::discovery::{assign_device_id, DEFAULT_REQUEST_TIMEOUT};

for (i, p) in probes.iter().enumerate() {
    let new_id = 0x02 + i as u8;
    get_async_runtime().block_on(assign_device_id(&bus, p.device_id, p.addr, new_id, DEFAULT_REQUEST_TIMEOUT))?;
}
  • It writes the new ID to 0001h over unicast to the module's own IP. That's why it works even while several modules share the old ID. A broadcast write would give all of them the same new ID.
  • Do one module at a time.
  • The write response still carries the old ID in ID_O (§8.2). The function matches the response by source IP instead.
  • It returns an error if the module refuses (for example SealProtected) or doesn't answer in time.

examples/assign_ids.rs wraps this in a CLI with --dry-run, which prints which IP would get which ID without writing anything, and --start-id. Run discover again afterwards to check that no collisions remain.

Clone this wiki locally