Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ jobs:
PeerStreamSession.swift ReticulumManager.swift mesh/Mesh.swift ble/MeshConnectionRegistry.swift \
ble/BleDiscoveryBootstrapPolicy.swift \
PeripheralRestorationAgeOutPolicy.swift \
BleMessageNotificationPolicy.swift \
BleHelloPolicy.swift BleMessageNotificationPolicy.swift \
BleAppTag.swift BleDensityPolicy.swift BleServiceInstanceSelection.swift \
Generated/offline_protocol.swift ForcedPresenceCheckQueue.swift \
AddressDeclarationPolicy.swift \
Expand Down
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,56 @@ archived by series under [docs/changelog/](docs/changelog/); see the
peripheral when a central is subscribed. The peripheral alone takes
nothing while no central is subscribed, where a notification reached no
one. No board run has been done yet.
- **An Android phone and a Mac running the Python service start a session
over Bluetooth LE.** They found and verified each other and never formed a
session: the Mac rotates its Bluetooth address, the phone kept a link to
each address, the Mac's peripheral then saw two centrals and attributed
every write to the placeholder `ble-peer`, and the core refused the phone's
key package as a transport identity mismatch (`TRANSPORT_IDENTITY_MISMATCH`).
The Python peripheral now names the central behind each write on macOS too,
serves the optional Hello characteristic, binds a central to the address its
hello proves and hands the core that central's fragments under that address.
The Python central writes its hello to a peer that serves one, bounded by
the characteristic's one-packet payload (bleak's BlueZ client reports a
23-byte MTU, which skipped every hello from Linux), and announces the peer
only once the hello is answered, so the key package the announce pushes
never reaches the peer ahead of it. The iOS central now writes its hello
too, once per connection, after its Message subscription and before its
handshake reads (`BleHelloPolicy`). Without it an iPhone could
verify a Python box and never start a session through the link it opened. A
link bound by a hello is never rebound, and a message's `sender` field never
replaces the binding. The iOS hello is written only when it fits one ATT
packet, since iOS sends a longer with-response write as a prepared write,
which a peripheral refuses for a hello. A central that says hello and never
subscribes has its binding dropped after 5 seconds, and its peer reported
lost unless another link reaches it. Two Macs and a Galaxy M36 (Android 16)
were run; no iPhone was.
- **Android and the Python central keep one client link per peer.** A second
link that verifies as a peer already held (a peripheral advertising under an
old and a new address) is closed unannounced, and both leave that address
undialed for a minute, or until the kept link is gone. Kept, two such peers
filled Android's four connection slots, and the cap then refused every
inbound central.
- **On macOS a peripheral notification reaches its recipient, and survives a
full transmit queue.** A fragment for a peer whose central said hello goes to
that central alone rather than to every subscriber, and a notification
CoreBluetooth refuses because its queue is full is retried once the stack
says it is ready. bless ignored the refusal, so a burst (a message, its
receipt, a key package) lost fragments and waited for the core's next retry.
- **A dead Bluetooth LE link is dropped within about 20 seconds.**
CoreBluetooth kept a link to a Mac whose service had restarted "connected"
for over five minutes, and every frame written into it vanished. The Python
central now reads each link's Device id back every 15 seconds and drops a
link whose read fails, takes more than 5 seconds, or names another address,
reporting the peer lost unless its central is still bound on our own
peripheral, which still reaches it.
- **A Mac that may not use Bluetooth no longer wedges the service.** Started
where macOS cannot grant Bluetooth (over ssh), the Bluetooth LE peripheral
blocked the service inside bless's constructor for good: no API socket, and
the 30 second transport start deadline never fired, because the block was on
the event loop itself. The peripheral now refuses to start when macOS reports
Bluetooth denied or restricted, builds its server off the loop with a 20
second deadline otherwise, and the service runs on its other transports.
- **A resend is offered to the mesh when no carrier reaches the recipient.**
Only a message's first send was handed to neighbours when no carrier took
it. A message whose first attempt went into a direct link that had already
Expand Down
208 changes: 200 additions & 8 deletions bindings/python/offline_protocol_sdk/ble_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@
MESSAGE_CHAR_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
DEVICE_ID_CHAR_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
IDENTITY_CHAR_UUID = "6e400004-b5a3-f393-e0a9-e50e24dcca9e"
# Optional: a central writes its identity assertion here so the peripheral
# can bind the link to its address (docs/spec/ble-framing.md, Hello).
HELLO_CHAR_UUID = "6e400006-b5a3-f393-e0a9-e50e24dcca9e"
# The chapter's bounds on a hello value, checked by both roles.
HELLO_MIN_LEN = 96
HELLO_MAX_LEN = 512

MAX_FRAGMENT_SIZE = 185
CONNECTION_TIMEOUT = 10.0 # seconds
Expand All @@ -59,6 +65,21 @@
# the global per-minute connection budget that verifiable peers need.
REFUSAL_BACKOFF_MAX = 600.0 # seconds
PEER_LOST_TIMEOUT = 30.0 # seconds since last seen
# How often each connected link is read back, and how long the read may take.
# CoreBluetooth can keep a link "connected" for minutes after the peer's
# process (and its GATT service) is gone, and fragments are written without
# response, so nothing else ever fails on such a link.
LIVENESS_INTERVAL = 15.0 # seconds
# How long an address refused as a second link to a peer already held is left
# undialed, unless the kept link dies first. A rotating peripheral advertises
# under both addresses for a while, and without this the address was redialed,
# verified and refused again every cooldown.
DUPLICATE_LINK_SUPPRESS = 60.0 # seconds
LIVENESS_READ_TIMEOUT = 5.0 # seconds
# How long the hello's write response may take. The announce waits on it, so
# a peer that never answers must not hold the link unannounced; Android's
# hello watchdog has the same bound.
HELLO_WRITE_TIMEOUT = 3.0 # seconds
SCAN_RESTART_INTERVAL = 30.0


Expand Down Expand Up @@ -116,6 +137,12 @@ def __init__(self, protocol: Any, device_id: str) -> None:
self._connecting: set[str] = set()
# address -> consecutive identity refusals; each doubles the cooldown
self._refusals: dict[str, int] = {}
# address -> (peer it duplicated, monotonic time the refusal lapses)
self._duplicates: dict[str, tuple[str, float]] = {}
# Set by ProtocolManager: whether another link (a central bound on our
# own peripheral) still reaches a peer, so a client link going away
# does not report lost a peer the core can still route to.
self.peer_linked_elsewhere: Any = None

# Metrics
self._bytes_sent: int = 0
Expand Down Expand Up @@ -214,6 +241,7 @@ async def stop(self) -> None:
self._device_id_to_addr.clear()
self._last_seen.clear()
self._connecting.clear()
self._duplicates.clear()

try:
self._protocol.ble_status_changed(is_available=False)
Expand Down Expand Up @@ -288,6 +316,9 @@ def _on_advertisement(

def _should_connect_locked(self, addr: str, now: float) -> bool:
"""Adaptive rate-limiting. Caller must hold ``_lock``."""
if self._suppressed_duplicate_locked(addr, now):
return False

# Per-peripheral cooldown
last_attempt = self._connection_attempts.get(addr, 0)
if now - last_attempt < self._cooldown_locked(addr):
Expand All @@ -301,6 +332,21 @@ def _should_connect_locked(self, addr: str, now: float) -> bool:

return True

def _suppressed_duplicate_locked(self, addr: str, now: float) -> bool:
"""Whether ``addr`` is inside a duplicate-link refusal. Caller must
hold ``_lock``. Lapses early once the kept link is gone, so a peer
whose kept link died is redialed at the address it still advertises."""
entry = self._duplicates.get(addr)
if entry is None:
return False
peer, until = entry
held = self._device_id_to_addr.get(peer)
kept = held is not None and held != addr and held in self._clients
if now < until and kept:
return True
self._duplicates.pop(addr, None)
return False

def _cooldown_locked(self, addr: str) -> float:
"""Per-peripheral cooldown, doubled per consecutive identity refusal
and capped at ``REFUSAL_BACKOFF_MAX``. Caller must hold ``_lock``."""
Expand Down Expand Up @@ -357,22 +403,56 @@ async def _connect_to_peer(self, device: BLEDevice) -> None:
await client.disconnect()
return

# A peripheral that rotates its address (macOS) advertises under
# the old and the new one at once, and both verify as one peer. A
# second client link to it buys nothing and makes the address map
# point at whichever verified last, so the first link is kept.
with self._lock:
held = self._device_id_to_addr.get(device_id)
duplicate = (
held is not None
and held != addr
and held in self._clients
and self._clients[held].is_connected
)
if duplicate:
with self._lock:
self._duplicates[addr] = (
device_id,
time.monotonic() + DUPLICATE_LINK_SUPPRESS,
)
self._emit_diagnostic("info", "Closed a second link to a peer already held", {
"address": addr,
"device_id": device_id,
"held_at": held,
})
await client.disconnect()
return

with self._lock:
self._refusals.pop(addr, None)
self._clients[addr] = client
self._peer_device_ids[addr] = device_id
self._device_id_to_addr[device_id] = addr

# Subscribe to message notifications
await self._subscribe_to_messages(client, addr, device_id)

# Say who we are on this link, after the subscription and before
# any Message write, as the chapter orders it. The announce below
# is what makes the core queue for this peer (it pushes a key
# package on it), so the hello goes first: a key package written
# ahead of it reaches a peripheral that cannot name this link and
# is refused as a transport identity mismatch.
await self._write_hello(client, addr)

# Announce the peer under the address it proved
try:
rssi = -70 # approximate; bleak doesn't expose RSSI post-connect
self._protocol.ble_peer_discovered(peer_id=device_id, rssi=rssi)
except Exception:
logger.debug("ble_peer_discovered failed for %s", device_id)

# Subscribe to message notifications
await self._subscribe_to_messages(client, addr, device_id)

self._emit_diagnostic("info", "Connected to peer", {
"address": addr,
"device_id": device_id,
Expand Down Expand Up @@ -456,6 +536,94 @@ def on_notification(_sender: int, data: bytearray) -> None:
"warning", f"Failed to subscribe to messages from {device_id}: {exc}"
)

async def _write_hello(self, client: BleakClient, addr: str) -> None:
"""Write our identity assertion to the peer's Hello, when it has one.

Without it a peripheral receiving our Message writes has no way to
tell which address they come from, and it refuses our key package,
whose sender must match the link's. Best effort, as the chapter
requires: no Hello, an assertion that does not fit one write, or a
refused write all leave the link as it was.
"""
try:
hello_char = client.services.get_characteristic(HELLO_CHAR_UUID)
except Exception:
return
if hello_char is None:
return
try:
assertion = bytes(self._protocol.identity_assertion([]))
except Exception as exc:
self._emit_diagnostic("debug", f"No identity assertion for a hello: {exc}")
return
if not HELLO_MIN_LEN <= len(assertion) <= HELLO_MAX_LEN:
return
# One ATT packet's payload, from the characteristic: bleak's BlueZ
# client reports a 23-byte MTU unless it was explicitly acquired, which
# bounded every Linux hello to 20 bytes so none was ever written. The
# characteristic reads BlueZ's MTU property (and on CoreBluetooth
# maximumWriteValueLength(.withoutResponse)), and a longer value would
# go out as a prepared write, which a peripheral refuses for a hello.
try:
payload_limit = int(hello_char.max_write_without_response_size)
except Exception:
payload_limit = 0
if payload_limit and len(assertion) > payload_limit:
self._emit_diagnostic("debug", "Hello does not fit the MTU; skipped", {
"address": addr,
"length": len(assertion),
"mtu_payload": payload_limit,
})
return
try:
await asyncio.wait_for(
client.write_gatt_char(HELLO_CHAR_UUID, assertion, response=True),
HELLO_WRITE_TIMEOUT,
)
except Exception as exc:
self._emit_diagnostic("debug", f"Hello write to {addr} refused: {exc!r}")

async def _probe_link(self, addr: str, client: BleakClient, device_id: str) -> bool:
"""Read the peer's Device id back; False when the link is dead.

A link whose peer process restarted can stay connected in
CoreBluetooth for minutes while every write into it vanishes. A read
of a characteristic that no longer exists, or that now names another
address, fails or times out, which this turns into a disconnect.
"""
try:
value = await asyncio.wait_for(
client.read_gatt_char(DEVICE_ID_CHAR_UUID), LIVENESS_READ_TIMEOUT
)
return bytes(value).decode("utf-8").strip("\x00") == device_id
except Exception:
return False

async def _check_liveness(self) -> None:
with self._lock:
links = [
(addr, client, self._peer_device_ids[addr])
for addr, client in self._clients.items()
if addr in self._peer_device_ids
]
if not links:
return
results = await asyncio.gather(
*(self._probe_link(addr, client, device_id) for addr, client, device_id in links)
)
for (addr, client, device_id), alive in zip(links, results):
if alive:
continue
self._emit_diagnostic("info", "Link failed its liveness read; dropping it", {
"address": addr,
"device_id": device_id,
})
try:
await asyncio.wait_for(client.disconnect(), LIVENESS_READ_TIMEOUT)
except Exception:
logger.debug("disconnect after a failed liveness read failed", exc_info=True)
await self._on_peer_disconnected(addr)

def _on_fragment_received(self, sender_id: str, fragment: bytes) -> None:
"""Handle an incoming BLE fragment from a peer."""
with self._lock:
Expand All @@ -476,18 +644,31 @@ async def _on_peer_disconnected(self, addr: str) -> None:
if device_id is not None:
self._device_id_to_addr.pop(device_id, None)

# A link that never proved a peer was never announced, so there is
# nothing to report lost.
# A link that never proved a peer was never announced, and one already
# torn down (a liveness drop runs this before the stack's own
# disconnect callback does) was reported then: nothing to report.
if device_id is None:
self._emit_diagnostic("debug", f"Unverified link closed: {addr}")
self._emit_diagnostic("debug", f"Link closed with no proved peer on file: {addr}")
return

self._emit_diagnostic("info", f"Peer disconnected: {device_id}")
if self._linked_elsewhere(device_id):
return
try:
self._protocol.ble_peer_lost(peer_id=device_id)
except Exception:
logger.debug("ble_peer_lost failed for %s", device_id)

self._emit_diagnostic("info", f"Peer disconnected: {device_id}")
def _linked_elsewhere(self, peer_id: str) -> bool:
"""Whether another link of ours (the peripheral's) still reaches
``peer_id``. Never raises: a probe that fails says no."""
probe = self.peer_linked_elsewhere
if probe is None:
return False
try:
return bool(probe(peer_id))
except Exception:
return False

# -- outgoing fragment handling -------------------------------------------

Expand Down Expand Up @@ -562,6 +743,11 @@ async def write_fragment(self, recipient: str, data: bytes) -> bool:
)
return True

def holds_peer(self, peer_id: str) -> bool:
"""Whether a connected client link of ours has proved ``peer_id``."""
client = self._find_client_for_peer(peer_id)
return client is not None and bool(client.is_connected)

def _find_client_for_peer(self, peer_id: str) -> BleakClient | None:
"""Find the BleakClient for a given device ID (O(1) via reverse map)."""
with self._lock:
Expand All @@ -576,7 +762,11 @@ async def _peer_cleanup_loop(self) -> None:
"""Periodically remove stale peers that haven't been seen recently."""
try:
while True:
await asyncio.sleep(PEER_LOST_TIMEOUT / 2)
await asyncio.sleep(LIVENESS_INTERVAL)
try:
await self._check_liveness()
except Exception:
logger.debug("liveness check failed", exc_info=True)
now = time.monotonic()
stale_device_ids: list[str] = []
with self._lock:
Expand All @@ -596,6 +786,8 @@ async def _peer_cleanup_loop(self) -> None:
stale_device_ids.append(device_id)
# Notify protocol outside the lock
for device_id in stale_device_ids:
if self._linked_elsewhere(device_id):
continue
try:
self._protocol.ble_peer_lost(peer_id=device_id)
except Exception:
Expand Down
Loading
Loading