Repository navigation
XTREM Bus and Discovery
This page covers xtrem::transport (the shared UDP socket) and xtrem::discovery (finding modules and
assigning device IDs).
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
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.
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.
| 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.
The receive task decodes every datagram, then:
- passes it to every
handle.events()receiver, atokio::sync::broadcastchannel with a backlog of 256 frames. Discovery uses this because it doesn't know the device IDs yet. - routes it to the
handle.subscribe(device_id)receiver for itsID_O, if there is one: atokio::sync::mpscqueue of 8 frames.
Each item is an Inbound { frame, from: SocketAddrV4, at: Instant }.
Routing rules:
-
One route per device ID. Calling
subscribeagain 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_Oalone, 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 withtry_send, so the receive task never waits for a slow consumer. Drain the queue every tick (XtremScale::handle_responsedoes this). -
unsubscribe(device_id)removes the route.
Datagrams without an STX aren't XTREM traffic and are ignored silently. IPv6 datagrams are ignored.
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.
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:
- It broadcasts a read of
0000h(serial number) toID_D = FF. It subscribes toevents()first, so a fast answer isn't missed. - It collects every serial-number response for
window(DEFAULT_DISCOVERY_WINDOWis 2 s). Results are keyed by source IP, so two modules with the same device ID are still told apart. - For each responder, it reads
0009h(sealing switch) and0100h(device state) over unicast, with a timeout ofDEFAULT_REQUEST_TIMEOUT(500 ms) each. - 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.
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
0001hover 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.
EtherCAT HAL
EtherCAT Devices
XTREM
Other crates