Commit Graph
850 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 b7a1ad47c2 The Oxford comma, always, the one-line description included
Every list of three or more takes the serial comma: the one-liner in
pyproject.toml, meshterm/__init__.py, and --help ("USB, Bluetooth, or TCP",
"Windows, macOS, and Linux"), the README, the changelog, the manuals, the
issue and PR templates, and six strings the app shows (the region-name
rule, a repeater setting's help, the diagnostics tool's description, and
the archive confirms). The Windows-console notice is rewrapped so its
comma keeps it inside 72 columns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:28:00 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5d0bf52717 The docs say what to do when a companion won't connect
A new page, docs/connecting.md, collects every connection failure MeshTerm
reports, with its cause and remedy: who pairs a Bluetooth companion on each
system (MeshTerm on Windows and Linux, macOS's own dialog there, where
--ble-pin does nothing), the terminal's Bluetooth permission on macOS and
its refusal over SSH, reading a screenless companion's PIN over USB,
forgetting a pairing on each system, and the dialout group and ModemManager
for USB on Linux. The README, the docs index, hardware.md, and the CLI
manual's failure section link to it, and --ble-pin says it has no effect
on macOS. The CLI manual's no-radio example shows today's message.

The changelog covers the Linux Bluetooth pairing and link fixes and the
per-step failure messages. configuration.md shows db_path, and a few lists
gain their Oxford comma, WiFi becomes Wi-Fi, and "never-before-seen"
becomes "heard for the first time".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 20:52:08 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 79137598cf Clock sync explains a radio whose clock is ahead instead of calling it malformed
MeshCore firmware sets its clock only forward: CMD_SET_DEVICE_TIME with an earlier time is answered ERR_CODE_ILLEGAL_ARG, which read as 'the device rejected the request as malformed'. Since 2026-09-24 the companion's clock has been ahead of this computer's (itself within 0.7 s of NTP), so every connect's sync was refused. set_clock now reads the radio's clock first: within a minute ahead is in sync and nothing is written; further ahead raises ClockAheadError, which says by how much and that a reboot resets it. The device layer also maps the firmware's refusal to that error, and the simulator's clock now only moves forward, like the firmware's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:10:09 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bd0c311dbc Bluetooth on Linux stops stranding its own link, and pairs with the PIN first time
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>
2026-09-25 04:37:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bd222105ae Two tests stop racing CI: the chat waits for its send, the BlueZ fixture leaves os alone
The BlueZ fixture patched os.getpid to return a string, and bluez.os is the
global os module, so Python 3.14's event loop (which opens a pidfd for its own
pid at startup) failed before any test ran. The agent path is now derived from
the real pid instead.

The end-to-end chat test piped Esc beside Enter, so a slow runner left the
screen and cancelled the spawned send before the row was written. It now sends
Esc only once the message is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 04:16:26 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 491325f995 Linux pairs a Bluetooth companion with its PIN, and a serial port says why it won't open
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>
2026-09-25 03:58:26 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 ed2ad3b7ab A Bluetooth connect that fails says which step failed
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>
2026-09-25 03:43:15 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 40183e89ca The live feed's SNR and RSSI labels start where their lanes do
Both labels were right-aligned over their numbers, so each ended
mid-value with the unit trailing past it; every other label on the row
starts at its lane's left edge. RSSI also borrowed the SNR's five-cell
field for a reading never wider than four (-120), which set the two
readings three or four cells apart instead of the row's usual two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:40:33 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6b694e3bd2 Packet classes take shorter names, and the class column narrows to 9
dir messg, chan text, chan data and anon req replace direct message, channel text, channel data and anon request, so the longest class (telemetry, chan text, multipart) is 9 cells and the live feed's class lane gives 5 of its 14 back to the row. The dashboard's traffic tallies and the viewer's headline read the same names from the same table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 03:07:11 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 d019c840a8 The live feed never collapses a packet's class name, and the PicoCalc drops its icon
The class name was dropped below the width the whole row needed, leaving only its icon. It is now drawn at every width, like the scope; a row wider than the screen is read by scrolling the highlighted one with ←→. The class icon beside the name is a family cue once the name is always there, so it follows Platform.menu_icons: on the PicoCalc, where it could only be a one-glyph stand-in, the lane is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:38:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 01a8f711d6 The session flood scope is framed by MeshTerm, so an older meshcore 2.3 can send scoped
reset_flood_scope and force_unscoped arrived late in meshcore 2.3.x. The uConsole's 2.3.7 satisfies meshcore>=2.3 without them, so every scoped channel send would have crashed on restoring the scope. set_flood_scope now sends the firmware's command 54 itself ([54][0][key], [54][0], [54][1]), as the default scope's command 63 already was. Every other library call MeshTerm makes exists in 2.3.7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 02:06:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b0d89a5ece A received channel message's card names the region it was flooded into
The radio hands a message over as text, not as the frame it rode in, so a 💬 row opened from the live feed had no scope to show. Its card now finds the message's flooded copies in the packet log, the way the message paths dialog does, and reads the scope off them into a scope row. The copies found are kept per row; the region is resolved on every paint, so a name learned while the card is open names it. Direct messages get no row: their copies are encrypted end to end, and a routed one has no scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:42:45 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fa9ef16f2b CLAUDE.md states the live-then-save editor and the one way to send a channel message
A device that holds each edit live until its own save gets the live-then-save shape (the region editor), never a stage; and every channel message goes through send_channel_in_scope under the transmit lock, since a direct send skips the scope window silently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:38:29 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b89cd266cc The region editor says when a repeater has no home region
The firmware parks its home mark on the wildcard when no region is home, so *^ means no home region set. The wildcard row's note now reads 'unscoped · no home' then, and 'unscoped floods' once a region is home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:38:00 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 79fe79a857 ^R resends the picked message unscoped, not only the newest
With a message picked, ^R (the Resend chip on the PicoCalc) offers that one: a scoped message of ours is resent unscoped, and a pick that isn't one offers nothing rather than reaching past it to another. With nothing picked it is still the newest. The pick clears once the resend lands at the tail, and the picked-row hint names ^R when it would act.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:37:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bdf166ce6d The live feed's SCOPE column never collapses
It came and went with the terminal's width: hidden between 70 and 83 columns, where the class label won, and always on the PicoCalc. It is now drawn at every width, and the class label is what gives way on a narrow screen, the icon still saying what the packet is. Beside the subject, the lane is on screen even at 53 columns; there it is the readings after it that scroll off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:24:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fcd70c2e2e The packet viewer states a flood's scope on a row of its own
The region rode the route row as a trailing atom (flood · scope harbour), easy to miss on a card opened from the feed's SCOPE lane to see it. Route now says how the packet was routed (flood, direct) and a labelled scope row underneath says where: the region, 'unknown region · code 3fa1', or 'unscoped'. A direct packet has no scope row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 00:14:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7f59bfa5a0 A channel's region is a bare title atom, and a channel with no scope answers -
The chat title reads "#devtest · onqc" rather than "#devtest · scope onqc". `channels scope N` on a channel with no scope prints - (null in --json) and exits 0: sending under the device default is an answer. Only an empty slot still exits 5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f0718ce7c1 The Channels list reads LAST before MSGS, and SCOPE only when a channel has one
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7b531fa83a The live feed's scope sits beside the subject, a dash when unscoped
SCOPE moves ahead of the SNR and RSSI readings, since where a flood was allowed to go is a fact about the packet like its subject; the readings are about how it reached us. A plain flood draws a muted dash instead of the word, so the lane is quiet until a row is scoped, and the lane narrows to 10 cells. The Channels list's column test rides along.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2976bf9e08 Scope means only what MeshCore means by it
The menu's This node / Other nodes / This app sections were called scopes; they now name whose a setting is, and scope is left to a flood's region alone. CLAUDE.md's lexicon says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 23:40:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 326f6f5479 Another command's refusal no longer ends the channel list early
The meshcore library does not serialize commands, and every command
waiting for "OK or ERROR" takes the first ERROR to arrive. At connect the
channel prewarm runs beside the clock sync, which this companion refuses
as malformed; a slot read handed that refusal took it for "no such slot",
ended the probe there, and cached a list missing every channel after it
for the rest of the session.

Only the firmware's NOT_FOUND (or a code-less refusal from old firmware)
now ends the list. Any other refusal is asked again once, and a second
one, or no answer at all, is a failed read the probe reports as
unfinished and does not cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:48:08 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 470959aeb1 The Channels list has a SCOPE column between the channel and its unread
The region each channel's messages are flooded into, blank for one that
sends under the device default. The lane is only as wide as the longest
scope, and shares a budget with the name lane so that at 72 columns it
never cuts the activity sparkline: with a name at its ceiling it gets
eight cells, what the row had spare. On the PicoCalc the row was already
cut inside the activity lane, so there it costs the LAST ages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:38:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 62119e6e28 Channel scopes, scoped packets and the default-scope fix are in the manual and changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 169c3272c7 The wildcard scope reaches the command, and an empty slot can't be scoped
On Windows, Click expands any argument holding `*` into the files it
matches, quoted or not, so `chat send --scope '*'` arrived as the working
directory's file names. The entry point turns the expansion off; nothing
MeshTerm takes is a glob.

`channels scope N REGION` on an empty slot did nothing and exited 5, which
tells a script the scope was set and read back empty. A write there is
now a usage error; a read still exits 5, as `share` does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:37:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 acd14b3adf An unnamed scope reads as one fact, not two title atoms
"scoped · 3fa1" put a `·` inside the reading, so in the message paths
title it chained as two status atoms. It is now "unknown scope 3fa1",
and "? 3fa1" in the live feed's SCOPE lane, whose heading already says
what the column holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:10:38 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 48444b7d54 The regions command and page are in the manual and the changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:07:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7c61c78696 A repeater's regions can be asked from its node page and edited on its admin page
The node page of a repeater shows the regions it was last heard to relay
floods for, whether it relays unscoped floods too, and when it said so, and
asks it once on request. The region store keeps that unscoped flag and the
answer's time beside the regions.

Repeater admin gains a Regions page: the region tree read over the remote
CLI (core/region_admin.py parses the dump, drops a line the 160-byte reply
cut in half, and recovers the rest from the flat lists), flood allowed or
denied per region and for the wildcard, home, default scope, add, remove
and save. Each edit is one command applied at once; the title counts what
a reboot would undo. meshterm regions NODE asks from the command line.

The simulator transcribes the firmware's region CLI, reply cap included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:07:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7cdc6d9ba3 A channel can be given a region its messages are flooded into
The firmware has no per-channel scope, so a channel send is now a window
on the companion's session scope: set it, send, restore it, under a
transmit lock every flood-capable send also takes (DMs, adverts, admin
commands, logins, regions/neighbour requests), so nothing interleaves.
Ack waits stay outside the lock. A scope the radio can't take stops the
message (FloodScopeError naming the firmware it needs) rather than sending
it unscoped or under the default; * checks the firmware version, since
before 1.16 the override would quietly fall back to the default scope.
A failed restore is repaired by the next transmission.

Channel scopes are set from the channel page's Send scope picker (known
regions with their carriers, or a typed name) and from the CLI (channels
scope, chat send --scope). The chat's title carries the scope as a status
atom, ^R/Resend sends the newest scoped message again unscoped after an
amber confirm, and the scope each sent message went under is recorded
(messages.scope, schema v17) so the paths dialog can say what it went
under and when no known repeater carries that region. Device config now
teaches the region store the default scope it reads or sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 21:02:34 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2785380d6b Packets say which region a flood was scoped to
The packet viewer's route row reads "flood · scope harbour" (or
"scoped · 3fa1" for a region nobody here has named, or "unscoped")
instead of the library's bare "tc flood"; a direct frame keeps its
route word and states no scope. The live feed gains a SCOPE lane where
the width holds it, 72 columns included (the subject lends it one
cell); the PicoCalc's row already scrolls at 53 and leaves the scope to
the viewer. The message paths dialog states a message's scope once, as
a title atom, read off its arrivals' frames. monitor streams a SCOPE
lane before NAME, and the scope object (or null) under --json.

The viewer and the feed take the region store's scope_of by injection,
falling back to reading the frame alone, and the viewer's card cache
keys on the scope so a region learned while it is open renames it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:50:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c3c0ccf9f9 A scoped flood can be traced back to the region it was sent into
The groundwork for region scopes. core/regions.py holds the firmware's
maths (key = SHA-256("#"+name)[:16] over UTF-8, code = HMAC over type byte
+ payload with the reserved values stepped off) and the Scope reading of a
received frame. core/region_store.py keeps the regions known by name,
which repeaters carry them, and each channel's send scope, and memoizes
resolution. Observations gain transport_code and scope_body (schema v16)
so a scoped frame resolves against a name learned after it was heard.

Device gains set_flood_scope (the session override, * forcing unscoped)
and request_regions (the anonymous REGIONS request). The default scope
is now framed by MeshTerm: the library stored it with a # the firmware
and apps leave off, and padded by characters rather than bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:36:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 49603f2b8b Why an SPI radio is added in config.toml and not on the device screen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:13:18 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 430f5b2daa --spi picks the one radio there is, and refuses to guess between two
--spi used to take whichever SPI profile config.toml listed first, a
coin toss that transmits once two radios are configured. It now counts
the radios actually attached: exactly one is used whether or not a
profile names it, which keeps the common single-radio case to a bare
--spi. Two attached, or two SPI profiles with nothing attached to count,
is a selection error (exit 3) that lists them and asks for -p, the same
way several serial devices with no default already are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 20:12:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f50db48074 meshterm devices lists the radio on the SPI bus
The device screen listed an SPI radio but the CLI inventory didn't, so
the one command for "what is attached?" was silent about it. The listing
moves into spiradio.spi_radios, which both now use, so the two can never
disagree; the row is marked active under --spi or an SPI profile.
resolve_spi becomes public for that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:59:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d5e8bb67d Adding a radio on the SPI bus has a walkthrough of its own
configuration.md now takes a non-AIO board from its meshtasticd config
to a working profile: the key-by-key mapping, a whole example, how to
check it, and what the profile deliberately doesn't hold (radio
settings, the preamble, identity). It warns that a key left out keeps
the AIO's value, unlike meshtasticd where it means off. --spi joins the
option lists in cli.md, the README, and the cookbook, radio/ joins the
state-files table, and config.example.toml carries a full wiring example.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:44:44 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9538bfedce MeshTerm drives an SPI radio itself, without the bridge service
A LoRa chip on the host's own SPI bus (the uConsole AIO) is now a
transport like serial, BLE, and TCP: --spi, an SPI profile, or the
picker's row for /dev/spidev1.0. MeshTerm starts a software node as a
child process under whichever Python has openhop-core, talks to it over
a loopback port, and ends it on disconnect; the node also exits with its
parent, so the pins are free whenever MeshTerm is not running.

The node keeps identity, prefs, channels, and contacts under
~/.meshterm/radio/<spidev>/, and on first use copies the bridge's
identity, contacts, and radio settings so it stays the same node.
Board wiring lives in a profile's spi table; radio settings are the
node's own, edited on Device config. Closes #21.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:48:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a93c331226 The SPI bridge remembers its channels across restarts
The radio library keeps its channel table in memory only, so every bridge
restart came up with no channels while MeshTerm's own mute settings
survived. Snapshot the table to channels.json after every set or clear,
the way contacts already were, and replay it at startup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 01:22:23 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 85fef1f430 The advert test's random source takes its draws as one sequence
Python 3.10's Random.__new__ refuses more than one positional argument, so
_Rng(*draws) broke CI on 3.10 whenever a test fed it two draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:15:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5021ab8de5 The weekly advert says where its week stands
A muted line under the Weekly advert row on the Preferences page, set in
the value lane: "next advert in 4d" while the week runs, "due — waiting
for a packet" and then "due — after 30 s of quiet" once it is due, "no
device connected", and "the week starts on Apply" while it is only staged
on. The line is a width-aware separator that reads the scheduler's new
status() on every paint, from state the loop already keeps, so it follows
the wait live without touching the file. It is drawn, even blank, for as
long as the preference is on, so the rows below never shift; every message
fits the PicoCalc's 53 columns, and a gallery specimen holds it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:29:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 6a8efa2566 A retired name is never reused, and files keep only what the code knows
A value can outlive its key in a file nobody saved since; if the name came
back with a new meaning, an old value that passes the new spec would load
cleanly and mean something else. So every name that leaves is recorded as
retired: preference keys in preferences.RETIRED (the two old advert cadence
keys), stored fields in their record dataclass's own RETIRED set. A test
walks meshterm.core for such records and fails any live name found retired.

Loading preferences refuses retired keys and records every drop (retired,
unknown, invalid); report_dropped logs them once logging exists, INFO for a
retired key and WARNING naming a typo. adverts.json now reads into typed
records and writes out of them, so unknown fields die at the next write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:00:13 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a7b664cdfb One flood advert a week replaces the background advert cadences
A MeshCore companion never advertises by itself, so the hourly zero-hop and
daily flood adverts MeshTerm sent by default were the only automatic adverts
on the air, and more than a companion needs. The Background adverts rows leave
Device config and `config advert-cadence` goes with them.

In their place, two preferences under Sending: weekly_flood_advert (off by
default) and advert_quiet_s (5/15/30/60 s, default 30). Each device keeps its
own real-time week, running from the latest of the switch-on, its last flood
advert (automatic or by hand), and its first connection; a due advert survives
a restart. Before sending, each connection must hear a packet, then the air
must stay quiet (nothing heard, nothing of ours sent) for the spell plus a
random 0-5 s, drawn afresh at every break so two waiting radios part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:54:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 42a97f4c5f The demo plays on the repository page
The top of the README showed a still that linked to meshterm.net, because
GitHub strips <video> from markdown and only plays a file uploaded to
GitHub itself. The recording is now uploaded, so the README carries it on a
line of its own and GitHub puts an inline player there. A logged-out
visitor gets the player too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 00:04:16 -04:00
Jean-Pierre MartineauandClaude Opus 5 278e9f6091 Every release page ends by asking for a star
One line after the changelog text, with the reason: GitHub ranks projects
by stars in its search and topic pages, so a star helps other MeshCore users
find MeshTerm. It lives in the workflow's template rather than the changelog,
so every release carries it and the changelog stays a record of changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:27:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 2bb543d29b The trailer check reads "Fixes #12" and trusts only a 404
It only recognised trailers written with a colon, so "Fixes #12", which
GitHub closes on just the same, went unchecked, as did fix, fixed, close,
closed, resolve and resolved. The colon is now optional and every closing
keyword counts.

It also treated any failed lookup as a missing issue, so a rate limit or an
outage would have left a public comment on a commit saying a real issue did
not exist. Now only a 404 does that; anything else is a warning in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:10:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 2be9fba424 The Python 3.10 tests stay on Ubuntu 24.04
Python 3.10 reaches end of life in October 2026, and the newer image that
ubuntu-latest moves to may not offer it. The leg would then fail on setup,
and the badge on the public README would turn red with nothing broken.
Pinning it keeps 3.10 tested for as long as MeshTerm says it supports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:10:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 2a73706cab 0.9.0 is rebuilt from today's main, so it carries today's fixes
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
JP's call: rebuild 0.9.0 rather than wait for 0.9.1. It has never been public, so the
release is recut from main: Python 3.14 inside the downloads, the corrected written
pages inside the app, companion and room server as the only names for those types, and
the legend that fits a PicoCalc.

[Unreleased] folds into the 0.9.0 entry. On a first release there is nothing for those
changes to have changed from, so they are not listed as changes; the entry gains the one
fact a reader needs, that pipx works on Python 3.10 to 3.14.

The release workflow writes the install half of the notes from its own template, which
still had the two lines fixed by hand on the first 0.9.0 page: "every Mac since 2020",
and "every file below" for a SHA256SUMS that lists only the binaries. Both are fixed at
the source. The changelog extraction also stops carrying the link definitions at the
foot of CHANGELOG.md into the notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.9.0
2026-09-21 21:38:30 -04:00
Jean-Pierre MartineauandClaude Opus 5 59eccba0a0 Every build runs on 3.14
JP builds with 3.14 now, so the workflows do too. The lint job moves from 3.10, the
packaging build job and the release's site rebuild and PyPI build from 3.12, and the
installers from 3.10. The test matrix is untouched: it still runs every version from
3.10 up, because 3.10 is still the floor.

The installers' comment said the oldest Python was chosen on purpose, for reach. The
reach that matters on Linux comes from glibc, and that comes from the runner, so the
Linux legs stay on ubuntu-22.04 and the comment now says that. The binaries carry 3.14
from the next release; 0.9.0's were built on 3.10.

The changelog gains the 3.14 entry JP wrote in another session, plus a line for the
downloads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:14:52 -04:00
Jean-Pierre MartineauandClaude Opus 5 5b7481af8a Python 3.14 is supported, and CI proves it
JP moved to 3.14. Every dependency installs on it -- the Windows Bluetooth packages
included -- and the suite passes: 2705 passed, 1 skipped, on 3.14.7 as on 3.10.

- pyproject gains the 3.14 classifier. requires-python stays >=3.10: the uConsole on
  bookworm runs 3.11, and a pip or pipx install on an older system should keep working.
- CI keeps its shape, which is Linux over the whole range and the other two runners at
  its ends. Linux gains a 3.14 leg, and 3.14 replaces 3.13 as the top end on Windows and
  macOS.
- Three tests reached a loop through asyncio.get_event_loop_policy(), which 3.14
  deprecates and 3.16 removes, and never closed the loop they made. The two countdown
  tests now make one with new_event_loop() and close it; the sweep test uses
  asyncio.run(), which does both. On 3.14 the suite is back to no warnings.

The standalone installers still bundle 3.10; moving their build to 3.14 is for 0.9.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 20:44:37 -04:00