LSB (Location Synchronization Bus)¶
LSB is the TDOA sync configuration for a project. There are two
distinct layers behind client.lsb, and they speak different
identifiers. Mixing them up is the most common source of confusion —
read this section before anything else.
Two layers — REST settings vs radio orchestration¶
| Layer | What it does | SDK methods | Identifiers it speaks |
|---|---|---|---|
REST settings (api/tdoa/settings + api/tdoa/schedules) |
Persist sync topology: which anchor is master, which are distributors, slot assignments, active schedule, autostart. | client.lsb.get / update, client.schedules.* |
anchor_uid, slot, sfd, master_uid |
| Radio orchestration (gateway parameter pushes) | Push the saved configuration to the actual hardware over UWB: writes sf + schedule + sync_slot plists to each master node and polls until they report in. |
client.lsb.start / stop / status |
mac, addr (4-char hex = firmware short_addr = node.node_id as hex), slot, sfd |
Updating REST settings does not push to the radios. Starting LSB
does not flip values.configured_state on the REST settings.
Both layers are needed to fully configure and activate sync.
Identifier cheat sheet¶
| Name | Layer | Type | Example | Source |
|---|---|---|---|---|
project_uid |
REST | str (22) | "_2wX1rVVRUub0T7_idmFrA" |
client.projects.list() |
active_schedule_uid |
REST | str (22) | "ARO1EJnfR1i4sdXb7d_yXw" |
client.schedules.create(...).uid |
anchor_uid |
REST | str (22) | "MqSTEw_1_GhW9YSET_GoTy" |
client.anchors.list() — server UUID for the placed anchor record |
master_uid (on a distributor row) |
REST | str (22) | null | same shape as anchor_uid, nullable |
Another anchor's anchor_uid; null means "this distributor is itself a master" |
mac |
Radio | bare 12-hex lowercase | "e4956ea406e9" |
The node bound to the anchor — client.anchors.list()[i].node.mac_address, or client.nodes.list() |
addr (a.k.a. firmware short_addr) |
Radio | 4-char uppercase hex | "0C9F" |
node.node_id formatted as f"{node_id:04X}" — see _hex_addr in resources/lsb.py:310 |
slot |
Both | int 0..255 | 0, 150 |
Caller-chosen — must be unique across masters + distributors |
sfd |
Both | int | 1000, 500, 250 |
Super-frame duration in ms (1 Hz / 2 Hz / 4 Hz). The Confluence spec validates this set explicitly. |
The radio layer never sees anchor_uid. The REST layer never sees
mac or addr. The SDK joins the two by looking up anchors →
anchor.node.mac_address → nodes → node.node_id → _hex_addr.
REST settings — client.lsb¶
| Method | What it does |
|---|---|
client.lsb.get() |
Fetch the project's LSB settings (GET /api/v2/tdoa/settings/). |
client.lsb.save(settings) |
Save an LsbSettings (POST /api/v2/tdoa/settings/). Server keys upsert on project_uid in the body; there is no PUT /tdoa/settings/{uid}. |
settings = client.lsb.get()
print(settings.lsb_version, settings.active_schedule_uid)
# LsbSettings is mutable (unlike the SDK's other models) — edit in place, then save.
settings.active_schedule_uid = "sched-new"
client.lsb.save(settings)
Wire shape and the typed members¶
On the wire the config is nested under values. On read the SDK lifts
it onto typed top-level members and drops the raw blob; on
save(settings) it rebuilds values from those members. The members
are the single source of truth. LSB/TDOA v2 only.
The server's values blob (for reference):
{
"active_schedule_uid": "ARO1EJnfR1i4sdXb7d_yXw",
"configured_state": true,
"autostart": true,
"masters": [
{"anchor_uid": "MqSTEw_1_GhW9YSET_GoTy", "sfd": 500, "slot": 0}
],
"distributors": [
{"anchor_uid": "DtSqRIq_1_GhW9YSET_GoT", "slot": 150},
{"anchor_uid": "DewTRIB_2_GhW9YSET_GoT", "slot": 151}
]
}
- Each master row defines a sync source:
anchor_uid,slot(0..254), andsfd∈ {1000, 500, 250}. Surfaced assettings.masters(list[LsbSettings.Master]). - Each distributor row carries
anchor_uidandslot(1..254). Surfaced assettings.distributors(list[LsbSettings.Distributor]). - An
anchor_uidmay appear at most once across the union ofmastersanddistributors(422 "Anchor … can not be used more than once").
To edit, set the member you want and call lsb.save(settings) —
LsbSettings (and its nested Master / Distributor) are mutable.
Typed read access — masters / distributors¶
The scalar config and the masters / distributors lists nested under
values are lifted onto the model on read (v2 shape). The raw values
dict is kept untouched.
settings = client.lsb.get()
for m in settings.masters: # list[LsbSettings.Master]
print(m.anchor_uid, "slot", m.slot, "sfd", m.sfd)
for d in settings.distributors: # list[LsbSettings.Distributor]
print(d.anchor_uid, "slot", d.slot)
masters / distributors are parsed into the nested
LsbSettings.Master / LsbSettings.Distributor models.
Model — LsbSettings¶
| Field | Type | Notes |
|---|---|---|
project_uid |
str \| None |
Required argument to update(). |
tdoa_version |
int \| None |
1 or 2; lifted from values if absent up top. |
lsb_version |
int \| None |
Sync protocol version (v2). |
active_schedule_uid |
str \| None |
Lifted from values. |
autostart |
bool \| None |
Lifted from values. |
configured_state |
bool \| None |
Lifted from values. |
masters |
list[Master] |
Lifted from values.masters; rebuilt on update. |
distributors |
list[Distributor] |
Lifted from values.distributors; rebuilt on update. |
Schedules — client.schedules¶
LSB settings select an active schedule via
values["active_schedule_uid"]. Schedule content (the per-slot table
of source / destination anchor, slot section, dimension, margin) is
carried as a serialized blob in Schedule.content. The SDK keeps that
blob opaque — its format varies by tdoa_version.
See Schedules for the resource surface.
End-to-end: configure a project from zero¶
The minimum dance to get a working sync configuration:
# 1. Pick the anchors that will participate (already created via
# client.anchors.create + bound to a node via
# client.anchor_associations.create).
anchors = client.anchors.list()
master = next(a for a in anchors if a.name == "Anchor-1")
dist_a = next(a for a in anchors if a.name == "Anchor-2")
dist_b = next(a for a in anchors if a.name == "Anchor-3")
# 2. Create a schedule. `content` is the serialized per-slot table
# matching `tdoa_version`; on a fresh project copy it from an
# existing template or paste a known-good blob.
sched = client.schedules.create(
name="default_schedule.csv",
content="#version 3\n0,,,0,0,2,0,0\n1,,,1,0,2,0,0\n2,,,2,0,2,0,0\n3,,,3,0,2,0,0",
tdoa_version=2,
)
# 3. Upsert LSB settings. The server requires the full `values` blob
# every call (no PATCH); the SDK rebuilds it from the members below.
from rtls_sdk import LsbSettings
client.lsb.save(
LsbSettings(
project_uid=client.lsb.get().project_uid, # or your own value
tdoa_version=2,
lsb_version=2,
values={
"active_schedule_uid": sched.uid,
"configured_state": True,
"autostart": True,
"masters": [
{"anchor_uid": master.uid, "slot": 0, "sfd": 500},
],
"distributors": [
{"anchor_uid": dist_a.uid, "slot": 150},
{"anchor_uid": dist_b.uid, "slot": 151},
],
},
)
)
# 4. Push the configuration to the actual hardware (radio layer).
# start() reads the settings above, derives tdoas from the active
# schedule and bcs from the slots, joins anchor → node → node_id,
# and pushes `sf` + schedule + `sync_slot` to each master MAC.
assert client.lsb.start() is True
Steps 1–3 persist intent on the server. Step 4 makes the radios act
on it. Skipping step 4 leaves you with a configured-but-quiet network;
skipping step 3 (or leaving configured_state=False) means the radios
will revert at the next autostart cycle.
Radio orchestration — client.lsb.start / stop / status¶
These drive the radios entirely from the saved settings + active schedule. All are no-arg (apart from optional tuning kwargs).
| Method | Returns | What it does |
|---|---|---|
client.lsb.start() |
bool |
Stop running masters, then push the configured masters; True once the radios report STARTED. |
client.lsb.stop() |
bool |
Zero the sf of every node currently reporting as master; True once none remain. |
client.lsb.status() |
LsbStatus |
Compare real vs configured masters. |
client.lsb.calc_tdoas(schedule=None) |
int |
max(slot) + 1 over the active schedule. |
client.lsb.send_schedule(schedule=None) |
None |
Push the active schedule to each participating anchor. |
start()¶
stop()— zero any masters already running (also persistsconfigured_state = False).- Read settings; persist
configured_state = True. - Resolve each configured master anchor →
(mac, addr=hex(node_id), slot, sfd). tdoas = calc_tdoas(active_schedule);bcs = max(slot over masters ∪ distributors) + 1.- Push
sfto each master (with a small send-retry), thensend_schedule(), thensync_slot. - Poll
status()until STARTED ortimeout; return whether STARTED was reached.
Raises RuntimeError early if there are no configured masters, no active
schedule, or a master's anchor has no in-scope node with a node_id.
stop()¶
First persists configured_state = False, then observes which nodes are
currently reporting as master (not the settings) and zeros their sf,
re-checking after each check_interval until none remain or timeout.
Returns True when stopped.
status()¶
from rtls_sdk import LsbStatus
match client.lsb.status():
case LsbStatus.STARTED: ... # real masters == configured masters
case LsbStatus.STOPPED: ... # no node reporting as master
case LsbStatus.UNKNOWN: ... # mismatched / partial
A node counts as a "real master" when its cached sf has a non-empty
addr and sfd > 0.
calc_tdoas / send_schedule¶
Both default to the project's active schedule (from settings); pass an
already-fetched Schedule to reuse it (e.g. start() fetches once and
hands the same schedule to both). calc_tdoas parses the v2 CSV and
returns max(slot) + 1; send_schedule pushes a per-anchor
tdoa_schedule plist built from the schedule rows + anchor geometry.
Wire format (what start() sends)¶
Per master, POSTed to /api/v2/win_message_que/create.json/ (one plist key per call):
{"message": {"mac": "aabbccddeeff", "plist": {"sf": {"addr": "1234", "sfd": 1000, "bcs": 1, "tdoas": 8, "bsd": 0, "tsd": 0}}}}
{"message": {"mac": "aabbccddeeff", "plist": {"sync_slot": {"addr": "1234", "slot": 0}}}}
stop() sends the sf plist with sfd/bcs/tdoas zeroed and addr
set to the master's own hex id. Both then poll
GET /api/v2/hw_params.json?mac=…&fields=sf (cached reads, batched at 20
MACs) to evaluate status().
Tuning¶
| kwarg | default | meaning |
|---|---|---|
timeout |
15.0 | Hard deadline; on expiry start/stop return False (no exception). |
check_interval |
0.7 | How often to poll hw_params. |
retries |
3 | (start only) send-retries per master on transport error. |
Common pitfalls¶
short_addris not on the REST API. It only appears at the radio layer asaddrin the wiresync_slot/sfplists. If the documentation or your code talks aboutshort_addrand the call site is hittingapi/tdoa/settings, you're on the wrong layer — switch toanchor_uid.start()/stop()manageconfigured_statefor you.start()persistsconfigured_state = True(after reading settings, before pushing);stop()persistsconfigured_state = Falsefirst. No need to flip it by hand around start/stop.- An anchor can't appear twice across
masters+distributors. Server returns 422 "Anchor{uid}can not be used more than once". sfdvalues are restricted to1000 / 500 / 250on writes. The Confluence spec is explicit; other values 422. (The orchestration layer acceptssfd=0only as the "disabled" sentinel forstop.)slotis 0..255. 256+ returns 422 "values.masters[i].slotmust be less than or equal to 255".tdoascomes from the active schedule.start()derives it viacalc_tdoas()(max(slot) + 1over the active schedule's rows), so the project must have anactive_schedule_uidset.
See also¶
- Schedules —
values["active_schedule_uid"]points at one. - Nodes —
send_param/read_paramslow-level primitives.node.node_idis the source ofaddr(firmwareshort_addr). - Anchors and Anchor associations — how a placed
anchor_uidresolves to anode.mac_addressandnode.node_id.