When a laptop wakes from sleep, the kernel can reset the USB device and hang
up the open port, but keep the same device file. The next write fails with
EIO, and the serial transport closes itself. The serial liveness check read
only the OS port list, so it said the link was up: no reconnect dialog
opened, and each command failed with no_event_received (issue #32).
The serial check now also reads the meshcore client's is_connected flag,
which the client clears when its transport is lost, as the BLE and TCP
checks already did.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each test docstring states the behaviour that the test proves, in the
simple present. No assertion message, test id, or other string literal
changed. Four statements now agree with the code: the Trace highlight
stays on the last row, a menu count, and two others.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every two seconds the liveness watcher asks whether the companion's serial
port is still there, and that question is a walk over /sys/class/tty: about
30 ms of small reads on an idle PicoCalc. It ran on the event loop. Each read
hands the GIL back and has to wait its turn to take it again, and while a
worker thread is busy (a map frame being drawn, a tile being decoded) that
turn is up to 5 ms away, hundreds of times over. A freeze watchdog on the
device caught the main thread inside this walk in 20 of the 21 stack samples
taken while the map was stalled, for up to 1.2 s at a time.
It now runs in asyncio.to_thread, as the device picker's and the reconnect
dialog's walks already did; the worker pays the wait and the screen doesn't.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The September messages reached the device picker and the command line, but
three other places still lost them. The reconnect dialog retried every
failure in silence, so a device that came back and refused (its port taken
by the MeshCore app, a stale pairing) kept the spinner up forever; from the
second failure in a row it now shows the reason under the spinner, and goes
back to waiting if the device is unplugged again. A device named on the
command line skipped the picker, and a failed start was dropped unlogged; it
is now shown before the menu opens. And a failed open left the dead device
behind, so is_connected said yes and every later tool failed with "not
connected": it is dropped, and the next tool tries again and says why.
TCP failures are named one by one (host not found, nothing on the port, no
route, no answer, hung up) instead of all reading "no response from a
MeshCore companion". A failure MeshTerm has no name for is no longer called
"not a MeshCore device": it is said in its own words, its traceback is
logged once, and the dialog says where the log file is. The tool-failure
dialog stops reading an error's brackets as markup, which had silently
deleted "[org.bluez.Error.Failed]" from Linux Bluetooth errors.
docs/guide/connecting.md lists the TCP messages and where each message
appears.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Over Bluetooth every command is a write-with-response, and a companion that
restarts on the reboot command can drop the link before acknowledging the
write. The write then hung until the link supervision timeout, holding the
reboot dialog back by a second or more. The reboot now returns once the
write has had a quarter second to fail, and leaves the rest to the teardown
that follows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reboot flow used to send the command and then wait for a liveness poll
to see the link drop. A quick reboot behind a port that never goes away
(a USB-UART bridge) was never seen, so the app went on sending commands to
a board mid-restart. Sending the reboot now declares the link down itself:
the session watcher wakes on the announcement, the Device rebooting dialog
takes over, and the first reconnect attempt waits a moment for the board to
actually begin restarting.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Measured against a T1000-E from a uConsole (BlueZ 5.66, btmon on the side).
A connect that BlueZ had to retry stranded its link. bleak retries a link
attempt that failed to synchronise (le-connection-abort-by-local, 3-7 per
connect on a Pi radio) inside connect(), and each one fired meshcore's
disconnect handler, which resets BLEConnection.client to None. bleak's
next retry then connected a client nobody held, meshcore gave up as "not
established", and the live link kept the companion from advertising, so
the retry scanned for 30 s and reported it out of range. Three runs out of
three. While connect() runs, a disconnect is now only recorded, and the
teardown keeps the last live client if meshcore let go of it anyway.
A companion given a PIN after it was paired unauthenticated answers the
subscribe with ATT 0x0E, Unlikely Error (bond Authenticated=0). That is now
read as a pairing refusal, so the stale-bond message and the PIN repair
reach it; with the PIN it re-paired and connected.
BlueZ's Pair left its link up (the companion then stopped advertising to
the connect), and failed at once on the same link flake bleak retries. It
now puts the link down after pairing and retries a flake a bounded 4 times.
And BlueZ starts a pairing of its own when the subscribe is refused, asking
the default agent; with none (any headless machine) it waited on a yes/no
until the companion's 30 s SMP timeout. For the length of a connect
MeshTerm is now the default agent for that one device, KeyboardOnly: with a
PIN it answers, without one it refuses at once, and it never approves a
pairing without the passkey, which is how a bond ends up weak.
Unpaired, no PIN: 33 s -> 4 s to "requires a PIN". Unpaired with the PIN:
paired and connected in 11 s. Paired: connects in 7 s, where it failed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Linux the PIN never reached the radio: bleak's BlueZ pair() calls
Device1.Pair and nothing else, BlueZ asks a pairing agent for the passkey,
and bleak registers none. Without a desktop agent BlueZ falls back to Just
Works, the firmware refuses a bond without MITM, and the picker looped on
"That PIN was rejected" for a correct PIN. MeshTerm now registers its own
Agent1 for the length of one pairing (core/bluez.py) and calls Pair from
the same bus connection, so BlueZ routes the passkey request to it and
never to the desktop's agent. The Windows repair contract carries over:
an existing bond is reused, force removes it and pairs afresh, and the
outcome is recorded so a refusal names it. Unpair & quit and the stale-
bond message work on Linux too, and the remedies there name Linux and
`bluetoothctl remove` instead of Windows' Settings. bleak no longer gets
the PIN on Linux, where it could only start a second pairing.
A serial port that refuses to open was reported as "didn't answer as a
MeshCore device", with nothing logged. Permission denied now names the
dialout group, a held port names the other program (and ModemManager on
Linux, also named when the port opens but never answers), and a vanished
port says so. A failed startup probe is logged at warning.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One "requires a PIN" sentence covered every GATT refusal, and a link that
never opened read as "didn't answer as a MeshCore device". A Heltec V4 user
whose Windows bond had gone stale was told to pair a device Settings showed
as paired. Each failure point now has its own message and remedy: link never
opened, stale Windows bond, wrong PIN, device refused to pair (with the
WinRT status), paired but still refused, a BLE probe timeout by stage, and
link up but no identity reply. The PIN dialog shows the diagnosis instead of
calling every refusal a rejected PIN, and each case is logged at warning so
a log sent in from another machine names it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Selecting a PIN-protected companion on a Mac reported an error, and selecting it
a second time connected -- the first attempt having quietly done the bonding.
CoreBluetooth has no pairing API: a peripheral pairs implicitly when something
touches a characteristic requiring encryption. The companion firmware puts its
UART characteristic at ENC+MITM precisely so that happens, so the unbonded
subscribe is answered with Insufficient Authentication *and* that answer is what
makes macOS raise its Passkey dialog. The GATT operation is dead by then; the
bond it began arrives seconds later, once a person has read six digits off a
device and typed them. Treating the refusal as a verdict therefore reported
failure for a pairing that was succeeding.
So on macOS the connect now waits for the dialog to be answered instead of
giving up on it, bounded so a dismissed dialog still fails in the end. Elsewhere
a rejection keeps meaning what it always did -- Windows and Linux can pair
explicitly, and do, before the link is opened.
The message that survives a dismissal no longer recommends --ble-pin there
either: macOS collects the code itself, so the flag cannot help, and naming it
sends the reader somewhere with nothing to offer. It names the dialog, and the
Bluetooth permission the terminal needs for one to appear at all.
The tests that cover a rejection now state which platform they mean, rather than
inheriting whichever runner they landed on -- the macOS leg of the matrix would
have failed on the old wording, and on a PIN it no longer forwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The menu is popped while a tool runs, so a lead-in question — the TX optimize link, the
node to manage, the password that follows it — had a blank base pushed under it to draw
as a box at all. A dialog is smaller than the frame, and what shows around it should be
the page it was reached from: the root the menu already declares for ^W is now the base
_floated pushes on an empty stack, and the reconnect dialog draws over it too, so the
menu loop stops forgetting its root on the way out. A session with no root declared
keeps the blank fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last 122 `D205` sites, which takes `ruff check .` to zero.
Same shape as the package pass and just as additive: these were a paragraph
with no summary, so each one gains a plain sentence saying what is being
asserted, and every word that was there moves down into the description. The
dense ones benefit most — `test_pathline`'s 23, where "A seam is one cell, not
two" now stands alone and the chevron mechanics explain it underneath, and
`test_tui`'s wide-glyph repaint test, whose seven lines on prompt_toolkit's
relative cursor now sit under the one line that says what the test pins.
Four module docstrings (`test_advert`, `test_gallery`, `test_platforms`,
`test_repeater_admin`) opened with "X tests: a, b, and c" and now name the
subject on the first line with the list below it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff check --fix` over the whole tree, ahead of putting the linter in CI:
1977 mechanical fixes, overwhelmingly `Optional[X]` → `X | None` (1186 in the
package alone) and the quoted annotations that `from __future__ import
annotations` already made unnecessary, plus import sorting and unused imports.
Nothing here changes behaviour — the suite is 1885 green before and after.
Four findings were real rather than stylistic and are fixed by hand: a dead
`cells` range in `MapCanvas`, a dead slot dict in the heat-ladder test, a
`ProgressScreen` task handle nobody read, and a tab loop binding a `label` it
never used. The two `F821`s were quoted forward references to names imported
inside their function bodies — now declared under `TYPE_CHECKING`, which keeps
`test_performance`'s local import (it guards the startup import graph) while
letting the linter resolve the name.
412 findings remain and are deliberately untouched: they need a decision about
the rule, not an edit to the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Bluetooth session that lost its companion could not get it back, and the
reason was one call that four separate layers each declined to make (JP,
2026-09-04).
When the peripheral is what goes away, bleak fires `BLEConnection`'s
dropped-link callback, which restores the connection's fields to what the
caller originally passed — setting `client` back to None and letting go of the
live object. `BLEConnection.disconnect` then guards on `self.client and
self.client.is_connected` and has nothing to close; `ConnectionManager
.disconnect` guards on a flag its own drop handler already cleared;
`_force_close_transport` reaches past both, but only as far as that same
`BLEConnection`, whose disconnect is the no-op above. Every guard is locally
right and the sum of them is that `BleakClient.disconnect()` is never called
on a link that dropped by itself.
On Windows the WinRT `BluetoothLEDevice` and its GATT session then stay open
for the life of the process, and every later connect to that address is served
a broken service table: the link comes up and the UART subscribe fails with
"Characteristic 6E400003-… was not found!". The device advertises at -61 dBm,
answers a hand-written APP_START over raw GATT with a valid SELF_INFO, and is
unreachable to this app until it restarts — while a fresh process connects to
it in 0.6s. That asymmetry is the whole tell, and it is what turned every
reconnect attempt into a fast, inexplicable failure.
So the client is ours now: taken at connect while the library still has one to
give, and closed unconditionally at teardown — never guarded on `is_connected`,
because the case that matters is exactly the one where it is already False.
Bounded and silent, since a teardown may not raise or hang, and a no-op on the
transports that have no client to hold.
Measured on the real companion: the session drops, is heard again 7.2s after
the device comes back, and is rebuilt 0.97s later — 8.3s from drop to live,
where it previously never recovered at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bluetooth notices a drop cleanly — the client's connection flag flips the
moment the peripheral goes — but the way back was two guesses stacked on each
other, and neither held (JP, 2026-09-04).
The reconnect flow gave serial a real question to ask ("has the port
re-enumerated?") and gave Bluetooth nothing: it simply attempted the connect
every couple of seconds. Each of those blind attempts drove bleak's own
internal address lookup into a device that wasn't advertising, which is a slow
failure, so the attempts sprawled across and past the window in which the
companion actually came back. And when one finally did land, it landed holding
the `BLEDevice` the startup scan produced — a handle that names a peripheral
the OS no longer has, because a device that power-cycles comes back as a new
one. The session opened over BLE and then, having dropped, could not find its
way back to a radio sitting a foot away with its light on.
BLE now gets the same shape serial has, asked of the only registry Bluetooth
keeps: `find_ble_device` watches for that one address's advertisement and
returns the instant it is heard. The answer arrives with a live handle, so the
listening and the fixing are one step — the reconnect opens the peripheral
that is on the air now, and `reconnect(ble_device=...)` prefers it over
anything the picker scanned. A miss costs a scan window rather than a failed
connect, and nothing is attempted against a device that isn't there.
The ordering is load-bearing, so the teardown splits out as `release_link` and
runs before the first scan: a companion we are still holding a link to does
not advertise, and the old flow's first act was a connect attempt that dropped
the dead client on its way past. Waiting first would have waited out a device
that was powered on and silent purely because we hadn't let go of it. The
resume intent is captured there and held across retries exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MeshCore.create_ble builds its client, calls connect() on it, and hands
it back only on success. A connect that *raises* therefore left that
client — and the bleak link it had already opened — orphaned inside the
library with no reference we could close. Our _mc was never assigned, so
disconnect() was a no-op and the probe's _safe_disconnect closed nothing.
That is not a corner: a PIN-protected companion answers the unbonded
notify-subscribe with a GATT authentication error, and the picker's first
attempt is always PIN-less. So selecting an unbonded companion leaked the
link, Windows held it for the life of the process, and the peripheral —
still believing it had a peer — stopped advertising. The PIN dialog then
asked for a code it could no longer deliver: the retry paired at the OS
level (the bond really is created) but could not form a GATT session, and
the user was told their correct PIN belonged to something that "isn't a
MeshCore companion". Since Unpair & quit is what leaves you unbonded, the
whole thing read as unpairing having changed the PIN.
Assemble the two objects here instead. _connect_owned_ble keeps the
handle, so both failure shapes close it: a raise (closed, then re-raised)
and a None handshake (closed, then reported as "not a companion"). The
teardown is shielded — the other way in is a probe whose wait_for expired
and cancelled the handshake, where a plain await would be cancelled the
moment it suspended and abandon the very close it was called to perform.
The picker's accepted PIN now also rides on the context, so a mid-session
reconnect doesn't re-run the whole "needs a PIN" dance against a companion
we already authenticated to. Session-lifetime only; nothing reaches disk.
Tests stop naming a real companion: the addresses and pairing code are
stand-ins now, and the fakes model the class we construct rather than the
create_ble we no longer call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).
The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.
Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.
Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.
That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.
The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.
tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A soldered SoC UART like the Luckfox Lyra's /dev/ttyS1 is never enumerated by
pyserial's comports(), so a serial [profiles.*] entry pointing at one could only
ever be reached via `meshterm --port` on the command line -- it never surfaced in
the startup picker, which listed only scanned devices (and TCP profiles). Fold
configured serial profiles into the picker too, mirroring the existing TCP-profile
folding, de-duplicated by port so a port pyserial *does* enumerate keeps its richer
scanned row.
Also fix the liveness poll for the same ports: serial_port_present() now treats an
existing /dev character device as present (platform UARTs aren't in comports() but
their node persists exactly as long as the port does; a USB node vanishes on unplug,
so this never masks a real disconnect). Without it the watcher would falsely report
a soldered radio unplugged mid-session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add TCP alongside serial and BLE as a first-class companion transport, end to
end: discovery/connection/selection learn a tcp: stable_id and host:port target,
--tcp and TCP config profiles reach a WiFi board or network proxy, and a network
companion -- which isn't discoverable -- is rebuilt into the picker from its
remembered endpoint.
Device picker gains the network-device affordances:
- "Add a network device" prompts for host:port, smoke-tests it, and remembers it.
The row is marked "- experimental" in the cautionary hue.
- Delete on a network row opens a chromeless Cancel/Remove confirm (Cancel left,
Remove right and default: Enter removes, Esc cancels), then DeviceStore.forget
prunes it and re-draws the list. Serial/BLE rows stay inert.
- The "Del remove" footer atom now follows the highlight, surfacing on the box's
bottom border only while the cursor sits on a deletable row; the box is sized
to the fullest footer so it never widens mid-navigation.
Folds in the accompanying courier, channels, and config refinements built along
the way. Screens stay readable at 72 cols; full suite green (745 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two long-standing flakes, one root cause each:
Quit with a serial companion often froze until the exit watchdog killed
the process. The meshcore dispatcher's stop() awaits queue.join(), but
its processor task exits after draining at most one event once stopped —
so a routine advert/RX-log burst (two+ events queued at that instant)
deadlocks the join forever (repro confirmed against the installed
library). MeshCoreDevice.disconnect now gives the graceful teardown two
seconds, then abandons it: the dispatcher task is cancelled synchronously
and the raw transport closed directly, so the port is still released and
even the forced path finishes well under the watchdog.
BLE startup often failed until the user re-selected the device. Connecting
by bare address makes bleak re-discover the peripheral with a fresh
internal scan, which on Windows intermittently misses a slow-advertising
companion. The discovery scan's live BLEDevice now rides along
(DiscoveredDevice -> probe/context -> create_ble) so the connect opens the
peripheral directly, and a transport-level ConnectionError is retried once
before surfacing — the manual re-select was never anything but a retry.
PIN/bond rejections still surface on the first attempt, unlooped.
Verified against hardware: BLE info read off the Waymarker connects and
exits cleanly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The on/off switch never made sense for a pure listener: recording is
now unconditional, so the toggle submenu, the persisted preference
(MonitorStore/monitor.json), and the header's ON/OFF framing are gone.
The monitor registers its hub subscription at startup — a device-free
step that cannot fail — and its run row opens lazily on the first
observation, so recording follows the hub wherever it opens (eagerly,
or deferred by connect_on_start) and silent sessions leave no empty
runs. The menu entry is now just the read side, "Heard nodes", and the
header shows live heard counters instead of a switch state.
meshterm monitor on the CLI drops --on/--off for a bounded foreground
capture (--seconds N, 0 = until Ctrl-C, mirroring chat listen): it
tails each overheard packet live and summarizes the window's heard
nodes when it ends, while persisting history exactly like a session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quit dialog grows a third button, shown only when the session is on a
Bluetooth link that Windows actually holds a bond for (never serial, an open
PIN-less companion, or a non-Windows platform). Choosing it disconnects the
link and then drops the OS-level pairing during teardown, so the next launch
re-runs the PIN ceremony. MeshTerm's remembered-device record is deliberately
kept — unpairing forgets the credential, not the device identity.
Backing this: static WinRT helpers is_ble_paired / unpair_ble mirroring
_pair_ble_windows (Windows-gated, best-effort), an active_address on the
context, and _can_unpair / _unpair_on_exit in the menu.
Also close the WinRT BluetoothLEDevice handles in every helper. Each
from_bluetooth_address_async returns an IClosable that pins the OS ACL link
open; unpair removes the bond but not the link, so a leaked handle left the
device "unpaired but still connected" and refusing to re-pair until it was
power-cycled. Closing the handle in a finally is what finally tears the link
down.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bleak's pair() on Windows only performs the CONFIRM_ONLY ("Just Works")
ceremony and never sends a passkey, so a PIN-protected MeshCore companion
bonds without authentication and then rejects the GATT subscribe on its
authenticated UART characteristic with Insufficient Authentication -- a
correct --ble-pin was reported as "rejected" and the device could never
connect.
MeshCoreDevice now runs the WinRT PROVIDE_PIN ceremony itself before opening
the link, creating the ENCRYPTION_AND_AUTHENTICATION bond the characteristic
requires; Windows persists it, so later reconnects need no PIN. A first GATT
auth failure with a supplied PIN also triggers one unpair-and-re-pair retry to
heal a stale unauthenticated bond from an older attempt. The step is a
best-effort no-op off Windows, when no PIN is set, or when already bonded.
Verified on hardware (AA:BB:CC:DD:EE:FF, MeshCore-Homestead): correct PIN
pairs and connects (reads model Seeed Tracker T1000-E), wrong PIN fails clean
with the "rejected" DeviceAuthenticationError, no PIN reports "requires a PIN",
and a bonded device reconnects with no PIN. Full CLI path exercised end to end.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a companion needs pairing, the interactive picker now opens a centered PIN
popup (its own bordered box under the wordmark, matching the splash) and retries the
connection with the entered code — re-asking with a "rejected" note on a wrong PIN,
and returning to the device list on Esc. The masked field clears its stale error as
soon as the user types a fresh code.
The connect failure is now a distinct DeviceAuthenticationError (a DeviceCommandError
subclass), so the picker can catch "needs a PIN" specifically while the scripted CLI
still catches the base class, prints the remedy (pass --ble-pin), and bails without a
prompt — no interactivity for scripted runs. --ble-pin already feeds the first attempt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A BLE companion that requires pairing accepts the link-layer connection but rejects
the GATT subscribe with "Insufficient Authentication", which bleak raised as a bare
BleakGATTProtocolError — escaping connect() as an unhandled traceback.
Translate that (and related "Insufficient Encryption"/"not paired" rejections) into
a clean DeviceCommandError that names the fix: pass --ble-pin, and pair once in the
OS Bluetooth settings on Windows. It's classified as its own actionable case, never
as a dropped link, so the session offers a PIN rather than a pointless reconnect.
The startup probe now lets this actionable error through instead of collapsing every
failure to a bare "didn't answer", so the picker shows the real remedy in the splash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Companions can now be reached over Bluetooth as well as USB. Rather than
a parallel BLE path, this generalizes the existing serial abstractions so
a device is identified by a transport (serial | ble) plus a target (COM
port or Bluetooth address); every feature rides the same Device interface
and works over either link unchanged.
- discovery: DiscoveredDevice gains transport/address/name and
transport-aware stable_id/label/confidence/target; discover_ble_devices
scans for MeshCore* adverts via bleak, discover_all merges both.
- connection: MeshCoreDevice opens create_ble vs create_serial by
transport; new link_present() liveness (OS port enumeration for serial,
the BLE client's is_connected flag for Bluetooth); is_connection_lost
recognizes Bleak errors and BLE transport callbacks; probe_device is the
transport-aware smoke test.
- disconnect: the session watcher polls transport-neutral link_alive(), so
a dropped BLE link raises the same reconnect dialog and auto-resume as an
unplugged cable; reconnect rebuilds the same endpoint by address.
- selection/context/store/profiles: --ble address, remembered BLE default,
and BLE device profiles all resolve to the right transport.
- UI/CLI: startup picker scans both transports behind a spinner and tags
Bluetooth companions; `devices` lists them (--no-ble to skip); global
--ble/--ble-pin flags; README and example config updated.
Validated on hardware: discovery, connect, reconnect, and channel
messaging (send + receive) over BLE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Watch the companion link while the menu runs; if it disconnects, float a
centered dialog with a spinner and a single Abort button while a
background task retries the connection. The dialog dismisses itself the
moment the device returns, so the user's only action is to give up:
Enter aborts, and Esc is deliberately inert so a stray keypress can't
drop the session while a replug may be a second away.
Give every popup dialog the same cyan reverse-highlight button. This
adds a "selected" theme style (and "err.reverse"): Rich silently renders
a style string that mixes a theme name with an attribute ("reverse
brand") as plain text, so the reverse is baked into a single-name theme
entry instead. Center the button chips with manual padding rather than
Text(justify="center"), which strips a styled span's trailing whitespace
and left the label hugging the fill's left edge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>