Skip to content

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 anchorsanchor.node.mac_addressnodesnode.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), and sfd ∈ {1000, 500, 250}. Surfaced as settings.masters (list[LsbSettings.Master]).
  • Each distributor row carries anchor_uid and slot (1..254). Surfaced as settings.distributors (list[LsbSettings.Distributor]).
  • An anchor_uid may appear at most once across the union of masters and distributors (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()

if not client.lsb.start():
    ...  # radios didn't reach STARTED within the timeout
  1. stop() — zero any masters already running (also persists configured_state = False).
  2. Read settings; persist configured_state = True.
  3. Resolve each configured master anchor → (mac, addr=hex(node_id), slot, sfd).
  4. tdoas = calc_tdoas(active_schedule); bcs = max(slot over masters ∪ distributors) + 1.
  5. Push sf to each master (with a small send-retry), then send_schedule(), then sync_slot.
  6. Poll status() until STARTED or timeout; 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()

client.lsb.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_addr is not on the REST API. It only appears at the radio layer as addr in the wire sync_slot / sf plists. If the documentation or your code talks about short_addr and the call site is hitting api/tdoa/settings, you're on the wrong layer — switch to anchor_uid.
  • start() / stop() manage configured_state for you. start() persists configured_state = True (after reading settings, before pushing); stop() persists configured_state = False first. 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".
  • sfd values are restricted to 1000 / 500 / 250 on writes. The Confluence spec is explicit; other values 422. (The orchestration layer accepts sfd=0 only as the "disabled" sentinel for stop.)
  • slot is 0..255. 256+ returns 422 "values.masters[i].slot must be less than or equal to 255".
  • tdoas comes from the active schedule. start() derives it via calc_tdoas() (max(slot) + 1 over the active schedule's rows), so the project must have an active_schedule_uid set.

See also

  • Schedulesvalues["active_schedule_uid"] points at one.
  • Nodessend_param / read_params low-level primitives. node.node_id is the source of addr (firmware short_addr).
  • Anchors and Anchor associations — how a placed anchor_uid resolves to a node.mac_address and node.node_id.