Commit Graph
12 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 d3735c8f7b BLE: a connect that fails must put the link back down
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>
2026-08-24 01:32:42 -04:00
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
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>
2026-08-01 03:40:42 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 f5880bfc20 Device picker: list configured serial profiles (soldered platform UARTs)
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>
2026-07-31 02:02:33 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 965783d276 Network (TCP) companions: connect, remember, and manage from the picker
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>
2026-07-14 02:38:11 -04:00
Jean-Pierre MartineauandClaude Fable 5 a65ce0ccf7 Exit in bounded time, connect BLE first try: teardown and startup hardening
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>
2026-07-10 22:16:36 -04:00
Jean-Pierre MartineauandClaude Fable 5 0f49cf0a2d Passive monitoring is always on; CLI monitor becomes a timed capture
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>
2026-07-10 02:07:23 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 12577a1538 Add "Unpair & quit" to drop a BLE bond on exit
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>
2026-07-09 20:46:02 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7d40929341 Pair PIN-protected BLE companions via the WinRT ProvidePin ceremony
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>
2026-07-09 19:58:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 588ee67c3b Prompt for a Bluetooth PIN in the picker; bail cleanly in the CLI
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>
2026-07-09 19:39:07 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 77390860da Handle PIN-protected Bluetooth companions gracefully
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>
2026-07-09 19:26:31 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 7850587e2a Add Bluetooth LE companion support alongside serial
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>
2026-07-09 18:44:39 -04:00
Jean-Pierre MartineauandClaude Opus 4.8 dd097e48e5 Prompt to reconnect or abort when the device drops mid-session
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>
2026-07-09 00:07:45 -04:00