Files
tines/tines-bt
kyleandClaude Opus 5.5 a85f57a869 docs(core,hosts): crate-level Stability section; fix comments that contradict the code
- tines-core's crate docs carry the spec §8 compatibility policy, as the
  README does.
- tines-bt: the module and BluerRadio::new docs claimed an optional or
  empty allowlist. The allowlist is exactly the configured peer (F3).
- tines: the poll loop called Store::flush "infallible". It returns a
  Result, and a failure is logged and retried on the next schedule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:44:09 -07:00
..
2026-10-22 08:44:09 -07:00

tines-bt

The BLE transport for tines: a Transport implementation whose address is the peer's advertised NodeId, so identity survives BLE address rotation.

Real radio I/O sits behind the small Radio trait (implemented over bluer/BlueZ by BluerRadio); everything else — the from/Addr check, the central/peripheral role decision, and the address→NodeId binding — is pure and unit-testable without hardware.

Framing

Connections are bluer l2cap::SeqPacket channels (LE L2CAP Connection-Capable Channel): each received SDU is exactly one frame. The MTU is clamped to MAX_FRAME, so a single SDU is a single wire envelope — no length-prefix framing is needed.

Roles

Every node advertises and scans. To keep two nodes from both dialing, the numerically-smaller NodeId acts as the BLE central (initiator) and the larger accepts on the SeqPacketListener bound to PSM 0x008D. The listener runs for the whole session.

Binding

The BLE address is only a routing key (it rotates), so a connection is bound to the peer's advertised NodeId instead:

  • the scanner decodes the peer's NodeId from a tines advertisement — the Service Data — 16-bit UUID record must carry the tines service UUID (0xFE7E), which is checked by adv::decode_ad_node_id;
  • the decoded bt address → NodeId pair is recorded in the scan map;
  • an inbound connection is bound by looking its peer address up in that map — an inbound from an unknown address is rejected immediately;
  • an outbound connection dials using the discovered address type, not a hard-coded one.

A frame's declared sender is then checked against the bound NodeId: the transport peeks the from field with frame_sender and drops any frame whose declared sender differs from the bound peer (an address-spoofing violation).

Lifecycle

  • connection up → Event::Contact(addr);
  • connection end (EOF, error, or adapter loss) → Event::Lost(addr);
  • scanning and listening continue after a loss.

Multi-peer

Up to MAX_PEERS (8, tines-bt's own cap, equal to tines-core's peer table) connections may be live at once, one per bound NodeId; a second connection to the same NodeId replaces the first.

Current limit: BluerRadio::new and BtTransport::new still take a single peer. The allowlist is exactly that one NodeId, and BtTransport drops frames and events from any other peer, so in practice each node exchanges with one configured partner. A configurable allowlist (empty = any tines advertiser) is still to come; see THREAT_MODEL.md F3.

Manual verification

The SeqPacket framing can't be exercised in CI (it needs two real radios), so verify it by hand with two devices running the tines service:

  1. start tines on both devices with distinct NodeIds;
  2. confirm the smaller-NodeId device dials and the larger accepts (check the tines-bt: connected to … (central) / accepted connection from … (bound) trace lines);
  3. send a burst of observations on one device and confirm the other receives each as a separate, intact frame (no merged or truncated frames).

No result has been recorded yet. Once it's done, note the date, hardware and BlueZ version here, along with whether SeqPacket worked or the length-prefix fallback was needed.