The firmware shipping next (firmware-base, #1896) refuses any channel but
Public in slot 0, even while it is empty, refuses Public in any other slot,
and refuses clearing Public. Every refusal is ERR_CODE_BAD_STATE, the same
code as an occupied slot, so the client has to avoid them, not interpret
them. Owner rulings, 2026-10-28: slot 0 "should always remain public",
"make sure Public can't be deleted", "Of course add the real public".
- decideChannelSlot takes a required isPublic. Public goes to slot 0 when
it is free; nothing else ever does. The fallback that lets Public take
another free slot is reached only when slot 0 holds some other channel,
which happens only on older firmware.
- The shared allocator takes the key being written and decides from it,
so Join Public, a typed private key, a QR share and a hashtag join all
follow the rule, and a second Public is refused on every one of them.
- planChannelImport follows the same rule for a stock config file.
- Deleting Public is refused at the attempt with "Public can't be removed
from the radio. It always stays in slot 0." The control stays live.
- Edit Channel locks Public's key, and refuses typing Public's key into
another channel while Public is on the radio.
- A hashtag join of "public" (leading # and spaces and trailing spaces
dropped, any case: the device's namesPublic rule) adds the real Public,
not sha256("#public").
The #1022 test that asserted Public has no claim on slot 0 is replaced by
tests for the rule above.
On older firmware, slot 0 is no longer handed to other channels, so one
slot of 40 goes unused there. That follows the owner's ruling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The channels screen was fixed in #1022, but two more surfaces chose a
channel slot on their own and had the same race.
Community QR scanner:
- A channel share link picked its slot before the confirm dialog and wrote
it after, so a sync during the confirmation (setChannel itself starts
one) could leave it pointing at an occupied slot. The slot is now chosen
after the user confirms, and a refusal keeps the screen open with the
reason showing.
- Joining a community skipped the public channel silently when the radio
was full and still showed the green "joined" toast. A refusal now
explains itself, and the green toast shows only when the channel was
actually written.
- Its private slot finder is deleted.
Config import:
- planChannelImport takes a required isSyncing. Mid-sync the connector's
channel list is empty, so the planner would have written every incoming
channel from slot 0 up, over Public. It now plans nothing and reports
each channel as skipped with a new channelsSyncing reason. The parameter
is required so a caller cannot forget it and get the unsafe behavior by
default.
Both screens now share allocateChannelSlotOrExplain
(lib/helpers/channel_slot_ui.dart), so the "which slot is free" question
has a single answer. channels_noFreeSlot is retired; its only caller moved
to the shared message.
Tests: two planner tests for the syncing case, mutation-checked (with the
guard disabled both fail). Full suite 1781 pass, analyze clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner reaffirmed: do not depend on FIRMWARE_VER_CODE, depend on caps bits.
The 0xC0 config gate ANDed OFFBAND_CONFIG_MIN_VER (14) on top of the
OFFBAND_CAP_WIFI_OBSERVER bit, which left it passing at zero margin once the
code rolled back onto 14. Removed; supportsConfig now takes only offbandCaps.
The bit is the sounder signal: caps byte 1 exists only on fork replies, which
are the only replies that carry 0xC0 at all, and a build without the observer
backend compiled in leaves the bit clear whatever version it reports.
minVerCode is deleted rather than left unused, so it cannot be reintroduced
into a gate. Two call sites and four tests updated.
Four gates now depend on caps bits alone: block, caplog, packet hash,
observer config. Those are all of the readers in the conflated 14-22 range.
Full suite: 1739 passed.
The companion "CLI console" entry was gated only on the target being a
companion. A pre-1.18 radio, including Offband before the absorb, refuses a
type-3 send: companion-v1.17.1 CMD_SEND_TXT_MSG accepts only plain and
CLI_DATA and answers anything else with ERR_CODE_UNSUPPORTED_CMD
(companion-v1.18.0 adds CLI_COMMAND). The connector only logs error
frames, so the command waited out its mesh timeout and the console showed
the #891 row, which blames the target for a limit of the user's own radio.
- Gate: DeviceCliScreen.offersRemoteConsole requires a companion target
AND a connected radio that speaks the 1.18 CLI (allowsCliFrame, the same
check that gates the local Device CLI; type-3 send and frame 66 shipped
together in 1.18). Disconnected or unclassified offers nothing. The chat
menu uses it.
- Fail fast: RemoteCliTransport fails the command at once with
CliConsoleUnsupported when the radio refuses the send. The window arms
only when the text frame is written (the onAttempt hook, after the path
update) and closes on the command's SENT, which on 1.18 carries an
expected-ack of 0. So an unrelated unknown-command refusal before the
write, or a DM's SENT, cannot be pinned on this command. Dispose
releases a command still waiting.
- Console: a refused send shows "<command>: not sent. Your radio's
firmware doesn't support remote CLI." (owner-approved), not the timeout
row.
Tests: the gate (positive and negative across populations, roles, and
disconnected); fail-fast on the refusal, ignoring other codes, errors
after the radio accepted the send, a DM's SENT, an error before the write,
and dispose; the console row.
Reviewed by a one-off Sonnet adversarial pass (Gemini budget down). Its
Medium finding, that the window armed at run() before the write, is fixed
here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extend the one CLI console (device_cli_screen, F2 #883/#884) to target a
permitted companion over the mesh (TXT type 3, #888/#889), reusing the
console rather than adding a screen or a chat-borne command path. Opened
from a "CLI console" action on a companion contact; command and reply
both live in the console.
- cli_console_transport.dart: CliConsoleTransport with a local and a
remote implementation, and one CliConsoleTimeout the console catches.
The remote transport sends with retries: 1 (no auto-retry - an
unpermitted companion queues the command and never replies, so a retry
only spams the mesh) and correlates the reply via the receivedFrames
listener, gated to CLI_DATA so a chat message or inbound command from
the same contact can't complete it (Sonnet review).
- device_cli_screen: optional remoteContact; "Remote CLI" + contact name.
- chat_screen: companion-only "CLI console" entry (Contact.isCompanion).
Also corrects #890: CLI and chat are separate surfaces. The receive
pipeline filed every contact frame into the conversation regardless of
TXT type, so a companion's CLI reply (type 1) and an inbound command
(type 3) leaked into the chat thread. A guard now drops CLI-typed
messages from the message pipeline - the console is their only surface -
and #890's in-chat command bubble is removed.
Review (Sonnet, one-off, Gemini budget down): redact secret reply text
in the remote path's late/unmatched log, matching LocalCliService;
dispose cancels the subscription; document that remote truncation is not
detectable post-reassembly.
Tests: transport round-trip / sender-filter / timeout / no-retry /
CLI_DATA gate; Contact.isCompanion; remote console title; and the
pipeline keeping CLI (type 1 and 3) out of the conversation while a plain
message still enters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1 target split. The command service now picks the message type by
the target contact's role: a companion (chat) gets TXT_TYPE_CLI_COMMAND
(type 3), a repeater/room server keeps the universal CLI_DATA (type 1)
it has always used. One line in the send path; the reply handling,
correlation and timeout model (which already carries the 600 ms
companion reply hold) are unchanged and serve both.
Companion replies come back as CLI_DATA (verified: MyMesh.cpp companion),
so the existing contact-agnostic reply listener routes them to
handleResponse unchanged; a UI entry point to open the CLI console for a
companion is the remaining downstream piece.
Tests: a real send proves companion -> type 3 and repeater -> type 1,
driven deterministically off the send frame. Full suite green (1432);
Gemini review: test race fixed, the reply-path concern refuted against
the source-verified CLI_DATA reply.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both scoped by the device-key prefix, and the legacy untagged data deleted
per the owner's decision 1 rather than orphaned.
**#854 path history: per radio AND out of SharedPreferences.** Two changes in
one migration, because doing them separately means two migrations over the
same data. The key was prefix + contact, so a route measured by a 1 W
handheld was applied to a repeater and the reverse. It now lives in drift as
prefix + device key + contact.
The move out of prefs is not gold-plating: that one store held 2,092 keys and
86% of a 4.28 MB file, and every prefs write on Windows re-encodes the whole
file (#923). This resolves#924 and makes every unrelated settings write
cheaper in proportion.
**#856 delivery observations: per radio, staying in prefs.** A repeater's
45-second deliveries were training the ACK-timeout model a 1 W handheld then
used. One small key, 3,091 bytes and 14 entries on the owner's install, so
unlike path history there is no cost argument for moving it and a move would
be churn.
**The legacy purge is chunked, and that is measured rather than guessed.**
Each prefs `remove` re-encodes and rewrites the whole file, so I measured the
real sequence against the owner's live prefs before designing it: 2,092 keys,
12.7 s of total work, mean 6.1 ms, but 32 ms for the first removal and under
1 ms by the last, because the file collapses from 4.28 MB to 45 KB as it
goes. I had assumed ~57 s and was wrong; it is tractable.
One unbroken pass would still block for 12.7 s, well past the 5 s watchdog,
which is the exact failure #917 just fixed. So it yields every 20 removals.
It runs after the startup loads so it never delays first paint, and it is
idempotent: no writes once there is nothing left, so running it on every
connect is safe.
Legacy and new cannot be confused, which is what makes the purge safe: the
new scheme writes to drift and the legacy keys are in prefs, so no length or
shape heuristic is needed to tell them apart. That is decision 2 making
decision 1 unambiguous.
**Wiring:** StorageService is shared with PathHistoryService and
TimeoutPredictionService, so the connector cannot own it like the other
per-radio stores. It is injected from main() and fed the device key on
SELF_INFO alongside them. Unset, both surfaces load nothing and never write,
so a read before the handshake cannot serve another radio's data (#754
precedent).
**Tests: Epic A's bleed tests inverted, 11 of them.** Every expectation that
demanded the bleed now demands isolation, and the before/after is the git
history of one file. Plus the purges: prefix-only, idempotent, and the
scoped data surviving the purge of the unscoped.
**Not done, and flagged rather than dropped:** the plan's B2 also said "bound
what persists to what PathHistoryService actually caches". That bound existed
to stop the prefs file growing. In drift a write costs one row and growth
costs nothing per write, so the bound would now delete regenerable route
history for no benefit. Skipping it is a judgement, not an oversight, and it
is called out here and in the PR.
The inventory's known-holes set is empty for the first time: {854, 855, 856}
at Epic B's start, then {854, 856} after B1, now none. An Epic A sanity
assertion that holes must exist failed on that and was corrected, because the
invariant that matters is conditional: if a hole is declared it must name an
issue, and empty satisfies it.
Citadel: meshcore-open-0z3, meshcore-open-z8t
Refs #937, #854, #856, #924, #853
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#855 was filed as "pending messages are stored globally, not per radio".
It is not a scoping bug. `loadPendingMessages`, `savePendingMessages` and
`clearPendingMessages` had no callers anywhere in lib/ or test/, and the key
was absent from the owner's live prefs. The `_pendingMessages` field in
message_retry_service.dart is an unrelated in-memory list that happens to
share the name.
So the surface is DELETED, not scoped: three methods and the key constant.
Scoping it per radio would have been three careful changes and a migration
decision, for an API nothing calls.
This is the finding that justified consolidating #854, #855 and #856 into
one Epic instead of landing three PRs. Nobody reading #855 on its own would
have gone looking for callers, because the issue already told them what the
fix was.
Removed with it:
- the inventory row, replaced by a comment saying the surface is GONE rather
than reclassified, so the history is readable
- A2's round-trip test, whose job was to prove the thing being deleted
actually worked. It served that and then became a test of nothing
- 855 from the known-holes expectation, which now reads {854, 856}; that set
shrinking to empty is what closing Epic B looks like
What still guards it: the inventory's "declared but vanished from source"
check fires if `pending_messages` ever comes back undeclared.
Citadel: meshcore-open-li1
Refs #937, #855, #853
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five tests pinning exactly what bleeds today, so Epic B has something
concrete to invert and cannot quietly half-fix it.
They use the REAL StorageService against real prefs, not the
FakeStorageService the existing path-history tests use, because the defect is
in the KEY rather than the service logic and a fake hides it entirely.
#854: the stored key is prefix + contact with no radio component, and a
7-hop route saved by one radio is served verbatim to another. A route
measured by a 1 W handheld gets applied to a repeater.
#856: observations live under one global key, and a service standing in for
a second radio starts up with the first radio's 45-second observation
already in its training data, having sent nothing.
#855: a round-trip test only, pinning that the API works before Epic B
deletes it, so the deletion is provably removal of dead code rather than of
a live feature. That it has no callers is a grep result recorded in the
inventory, which a unit test cannot assert.
These assert the BUG deliberately and are named so nobody mistakes them for
desired behaviour: each title says BUG and names what Epic B replaces it
with. The plan's A2 asked for genuinely-red tests; a red test cannot merge,
and Epic A's PR has to land before Epic B starts, so these are
characterization tests instead. They demonstrate the bleed just as concretely
and go red the moment it stops, which is the property that matters. Flagged
here rather than quietly substituted.
Two API names in the first draft were invented (loadHistoryFor,
loadObservations) and did not exist. Replaced with the real ones after
reading the services: the path-history case now exercises StorageService
directly, which is where the defect actually is.
Citadel: meshcore-open-ss9
Refs #936, #853, #854, #855, #856
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing trigger hangs the Android main thread, so its trace is a Java stack. That proved the capture works and that our Kotlin frames resolve via the R8 mapping, but it cannot say whether our own DART frames survive into a trace, which is the question #906 was filed to answer.
This one blocks the Dart UI isolate the way the real ANRs do rather than with an artificial spin: it repeatedly JSON-encodes a large nested structure shaped like the stores #334 is about. The Play clusters show the real ANRs inside the JSON stringifier and under allocation pressure, so a trace from this block should match their shape, and whether any of our Dart frames appear above those is then readable.
It spins deliberately. Any await in the loop would yield to the event loop, the isolate would keep answering input, and no ANR would fire. If the process survives the full duration it logs why, so the tester is not left guessing.
Gating is unchanged and still doubled: the bool.fromEnvironment constant keeps both rows out of any shipped build, and the owner-only Experimental section hides them again in a test build. Both rows are relabelled native side and Dart side so they are distinguishable.
Tests: both triggers asserted to be immediate no-ops off Android, which matters because a missing platform guard would hang a desktop build instead of failing visibly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
43 tests. Selection covers the dedup contract: strictly-newer only, a record equal to the watermark treated as already logged, two records in one millisecond both kept, the watermark taking the highest rather than the first, and the backfill trace cap.
Formatting covers the cases that would otherwise read wrong: zero pss and rss render as unknown rather than as a real measurement, a null description is omitted rather than printed, an absent trace records why, and a backfilled record says so. Every message the service can surface is asserted present for its own failure, and an ideal line is asserted to carry none of the failure markers.
The channel layer runs behind a mock handler, which proves the watermark advances on success, does not move on a query failure so the next launch retries, and that collect never throws and runs once per launch.
What these cannot prove, stated in the test file header: that Android actually produces a REASON_ANR record with a readable trace. That needs a real main-thread block on an API 30+ device and a relaunch, which is #911 part B.
The rotation tests pin down what a 40 KB entry actually does, which is the Epic's rotation criterion: it rotates once and is written whole, the active file is then legitimately above maxBytes because that gate decides when to rotate rather than truncating, and maxFiles still holds so the log cannot grow without bound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(#881): frame 66/29 CLI codec with cc| correlation
Pure codec per the 1.18 contract: ASCII fail-closed builds, lenient reply decode, truncation bound judged on the raw payload. Gemini findings 1+2 fixed, 4 documented.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#882): local CLI service + shared cc| correlation helpers
PrefixAllocator and splitCorrelated extracted; the repeater service delegates unchanged in behavior. LocalCliService runs correlated frame-66 round trips; the connector routes RESP 29 to listeners.
Agent: GentleGlacier (session f0c92a36)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(#883): device CLI console screen
The local-device counterpart of the repeater CLI screen (interop plan
F2/E2.1): type a command, it goes to the connected radio over frame
66/29 via LocalCliService, and the correlated reply renders with a
per-reply latency chip. No path/flood selection and no retries - one
send, one frame-level timeout window surfaced as its own row.
Built to the owner-approved behaviors: masked secret replies
(long-press to copy, cleartext never in the widget tree), the 165-byte
truncation badge, string-matched error styling, and command history.
Entry point and population gating are #884, deliberately not here.
Security fix surfaced by the #883 Gemini review: LocalCliService logged
the cleartext of an unmatched (late) reply, so a secret reply arriving
after its command timed out could leak to the app log despite the
screen's masking. The log line now withholds the reply text (token +
byte length only); a test pins it.
Also replaces em-dash characters in the sibling E2.1 files flagged by
the #464 CI guard.
Tests: 12 widget tests for the screen and a service test for the log
redaction. Full suite green (1207 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(#902): declutter Settings - user-ops to Actions, reorder Debug
Owner-directed Settings hygiene, split from the #884 entry point so the
Device CLI row lands in a clean Debug pane.
Moved to the Actions pane (they are user operations, not diagnostics):
config Export/Import and the three GPX exports (the whole export card),
plus "Sync queued messages now".
Debug pane reordered to App Debug Logging, Companion Debug, App Debug,
Serial Capture, Mesh Topology (which stays - useful diagnosing with a
user). The Device CLI row at the top is #884, not here.
Behavior preserved: every row keeps its handler, icon and copy; only
their pane and order changed.
Tests: a new widget test pins the spec - the moved rows present under
Actions and absent from Debug, and the full Debug order. A Gemini review
finding of a Debug-ordering defect was refuted (the order matches the
spec; the review misread the toggle for the viewer); its secondary point
was taken and the test now pins the full order. Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#884): Device CLI entry point + capability gate
Adds the entry point for the console (screen landed in #883) to
Settings > Debug. A "Device CLI" row appears at the TOP of the Debug
pane only when the classifier places the connected radio at stock v14+
(classification.allowsCliFrame): stock 1.18+ today, Offband at the
upstream-1.18 absorb via the D1 seam. The row is absent on stock-legacy,
Offband, and when disconnected (classification null). Tapping it opens
DeviceCliScreen.
The gate reads the capability through context.select, so only that
boolean flipping rebuilds the pane, not every connector update (#81).
Depends on the classifier from dev (merged into this branch).
Tests: positive (stock 1.18 shows at top, opens the console) and three
negatives (disconnected, stock-legacy ver 13, Offband all hidden). Per
the Gemini review the test's frame helper now asserts the version fits.
Full suite green (1244 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#881): dart format the codec test (CI format gate)
test/cli_command_codec_test.dart (landed in #881) was not formatted, so
the branch's first run of the "analyze" job (flutter analyze
--fatal-infos + dart format --set-exit-if-changed .) failed on it.
No logic change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#885): mirror set-name character rejection + CLI l10n
The firmware's `set name` rejects the characters [ ] \ : , ? * and
bounces a bad name as a cryptic ERROR. Mirror that client-side so the
name is caught before it is sent, in both places a name is set:
- Settings node-name dialog: live inline error, Save blocked on an
invalid name (nothing sent).
- Device CLI console: a `set name <value>` with a forbidden char is
echoed with a rejection row and never sent; the device stays the
authority for everything else.
Shared validator in lib/helpers/device_name_validator.dart
(forbiddenCharsIn is distinct + canonical-order). Also moves the
"Sync queued messages now" row strings and the new validation messages
onto app l10n (EN copy unchanged).
Tests: validator unit (the exact seven chars, order-stable, deduped),
console pos/neg (+ rejected command still recallable from history, per
the Gemini review), and the Settings dialog pos/neg. Gemini review
otherwise clean; its one finding (rejected command not in history) was
refuted - history derives from the transcript, which the rejection path
appends to - and pinned with a test. Full suite green (1255 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: diffwireauto <330573775+diffwireauto@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Channel notify modes lived in one global AppSettings map keyed by PSK or a
bare slot index, so one radio's settings bled into another and set-and-back
could not clear a stale alias (#839). Scope them by the connected device-key
prefix like every other per-radio store (prefix -> pskHex -> mode); resolve
All when the radio or PSK is unknown instead of falling back to the slot
index; store a single entry per channel so All truly resets. One-time
migration moves the legacy flat map and name-keyed mute set into the first
radio synced after upgrade; later radios start clean. Removed the now-unused
legacy muteChannel/unmuteChannel/isChannelMuted methods so mutedChannels is no
longer a second, cross-radio source of truth (pre-PR Gemini review finding).
Buzzer Inherit (#544/#545) reads the same path and inherits the fix.
Epic #844. Build #848.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR Gemini review (Epic #814) flagged a use-after-dispose: if the screen pops while a discovery is in flight, the completing future would call notifyListeners on the disposed service and throw. Added a _disposed flag + _safeNotify guard + dispose override, with a regression test that disposes mid-discovery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RegionDiscoveryService (ChangeNotifier) drives one discovery run via an injected discoverer function and exposes a distinct state for each outcome: idle/loading/success/empty/timeout/error. Regions deduped by Region value-equality. Concurrent starts ignored. Injected seam keeps it unit-testable without a live connector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every preset test faked the network, so presets missing from
config-profiles main passed them all and reached the owner as
"Couldn't update presets" on a test build twice. The live test
refreshes from the real URLs and fails on any refresh error; a second
live test proves a missing file comes back as HTTP 404. The picker
test now checks that a good refresh shows no error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two app-side halves of the #702 fix.
The native USB service read one 8 KB chunk per flserial callback, which
left the rest of a burst waiting in the native ring. It now drains
until the ring is empty, bounded at 32 reads (the ring's 256 KB) so a
stuck read cannot spin.
The frame decoder skipped bytes it could not frame (noise, an oversized
length) without a word, so when bytes were lost upstream, whole frames
vanished with no trace. It now counts them and keeps the first 16; the
native USB, web USB and TCP services log each batch as an error with
the count and a hex sample (SAFELANE 6). The web service shares the
decoder, so it gets the same logging, matching the plan's "all
transports" intent.
Tests cover no report on clean input, noise before a frame, an
oversized header, take-once, the capped sample and reset; with the
count disabled, 4 fail.
Epic #702.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RadioPreset.frequencyHz returned kHz; renamed frequencyKHz (the unit
CMD_SET_RADIO_PARAMS carries). The picker is disabled while a refresh
runs, so a pick can't vanish when the refreshed list lands, and the
selected-item builder uses an id-to-title map.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RadioPresetService starts from the last good copy of each preset file,
or the copy bundled in assets/radio_presets/ (config-profiles 857d4e9),
so the picker works offline. refresh() fetches both files from
config-profiles; each file that parses becomes the new saved copy, and
one that fails or comes back empty never replaces a good one. Failures
are logged and kept in refreshError for the picker to show.
refreshIfStale() refreshes at most every 12 hours. The merged list puts
the overlay over upstream on a matching title, sorted by region.
An overlay edit in config-profiles therefore reaches users on their
next refresh with no app release (test: "an overlay edit reaches the
app with no new build"). Closes the gap recorded in #648.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adversarial review raised three findings. Two survived verification.
Rejected: a claimed RangeError when abbreviating a short public key. The
code already guards with a length check before either substring, so the
crash it describes cannot occur.
Rejected as described, fixed as found: contact position (0, 0) was said to
be dropped on import. It is not. The frame builder writes the position block
whenever lastModified is present, which it always is here, so a suppressed
position still writes 0/0 and the bytes are identical either way. The
hasPosition conditional was therefore doing nothing except misleading a
reader, which is exactly what happened. Removed, and the behavior is pinned
with a test.
Accepted: the service added sections to 'applied' after sendFrame returned,
which only means the frame left our side. A lost or refused frame was
indistinguishable from success, so the user could be told an import worked
when nothing changed on the device.
A correct fix needs an awaited per-command acknowledgement in the connector,
keyed on command code so concurrent commands cannot steal each other's OK.
That is real connector work and is filed as #584. What is fixed here is the
overclaim: 'applied' and the two counts are documented as sent rather than
confirmed, and the result string now reads 'Sent N contacts and M channels
to the device'. The code no longer states something it cannot know.
Applies a stock config file to the device with stock's own merge semantics,
quoted from its Import Config screen:
- contacts are upserted on public key, and nothing is ever deleted
- channels are additive only, so a rotated PSK for a channel the user
already has does not apply
- importing an identity overwrites the node outright
We match that behavior but not its silence. Every skipped channel comes back
named, with a reason, so the UI can show it; a channel that quietly did not
import is the silent no-op SAFELANE section 6 forbids.
The channel merge rule is extracted as planChannelImport, a pure function, so
the surprising part is testable without a radio. It treats a channel as
already present when either its PSK or its name matches, which is the
non-destructive reading of a rule stock states without defining. Pinning down
what stock actually does is a T1 question, recorded in the doc comment.
New channels take the lowest free slot, since the file carries no index.
other_settings is reported as not fully writable rather than claimed as
applied: buildSetOtherParamsFrame deliberately pins the auto-add-contacts
byte, so manual_add_contacts cannot be written without changing app-wide
behavior. Advert location policy is applied.
10 tests over the planner and the result type.
Gathers live device state into a StockConfig, section by section, matching
stock's Export Config screen where each section is independently selectable
and the file simply omits what was not ticked.
A requested section that cannot be gathered is reported in the result rather
than dropped, with the reason kept specific: unsupported (firmware built
without identity export) is distinct from noReply and rejected, so the
export screen can say the radio cannot do it instead of offering a pointless
retry.
Two unit traps handled explicitly:
- currentFreqHz is misnamed; the value is kHz, which is also what stock's
frequency field carries, so it passes through unconverted. currentBwHz
really is Hz. The mismatch exists in both our state and the file.
- other_settings.manual_add_contacts must be the raw device byte. The
connector's _manualAddContacts is an inverted derived view of bit 0
(firmware treats a clear bit as auto-add enabled), so exporting it would
have written the wrong value. The raw byte is now retained and exposed,
with the inversion documented at the parse site. No behavior change.
Path mapping keeps our hash count and per-hop width (#309) intact: stock
encodes one hash per comma-separated element, so the width survives as
element length. A flood route, an over-long declared hop count, or a width
stock cannot express all yield no path rather than a guessed one.
13 tests over the pure mapping functions.
The radio is authoritative for the expected-ACK hash it reports, but the
client looked that hash up in a map keyed by a hash it recomputed locally.
When the two disagreed the lookup missed silently (debugPrint only), the
8000ms watchdog from #395 marked the message failed, and the genuine ACK
later matched nothing because every downstream map is populated only on the
match path. Delivery worked the whole time.
Captured on a Wadamesh HV4 TFT in #449: five DMs, all shown as errors, two
with a confirmed ACK whose hash equalled the radio's RESP_CODE_SENT value
exactly. Wadamesh builds the payload differently, so the client cannot
predict its digest, and Offband is expected to work against stock MeshCore,
Wadamesh and other forks.
RESP_CODE_SENT is the reply to our own CMD_SEND_TXT_MSG, so adopt the radio's
value when exactly one send awaits confirmation. Zero or several candidates
keep the previous behaviour rather than guessing. Channel sends draw the same
frame, so adoption is refused while one is outstanding.
The lookup miss is now a warn on debugLogService instead of a bare
debugPrint (SAFELANE section 6).
A send-order correlation queue was developed alongside this and has been
split out: its ordering premise needs a transport send mutex that does not
exist yet, and three review rounds each found a fresh defect in it. This
commit deliberately carries only the adoption fallback, which is what the
owner's hardware test validated.
Refs #449
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. An unprefixed reply no longer resolves an arbitrary pending command.
#532 constrained prefixed replies only; the unprefixed fallback still
used firstWhere, which with two or more in flight is map-iteration
order, so a caller could receive another command's output. The
fallback cannot just be deleted: firmware only echoes the prefix when
the command exceeds four characters including it
(simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'),
so a very short command legitimately answers without one. It now
resolves only when exactly one command is pending, and surfaces the
ambiguity otherwise.
2. A late reply no longer overwrites unsaved edits. The settings screen
applied a late `get` payload unconditionally, so a reply arriving after
its timeout could revert a field the user had typed into while waiting.
It now applies only when nothing is dirty, and says which happened.
3. _expiredCommands is pruned on timeout as well as on receive. It was
pruned only in handleResponse, so a run of commands that all time out
with no traffic coming back accumulated records until disposal.
The reviewer described 3 as unbounded growth. It is bounded at 256 by the
prefix token space, and stale records could never be mis-attributed
because handleResponse prunes before matching, so the severity was
overstated. Fixed anyway; it is nearly free.
A fourth finding, that the new l10n strings are untranslated in the other
17 locales, is rejected. That is this project's established pipeline:
strings are authored in app_en.arb, gen-l10n emits English fallbacks, and
untranslated.json tracks the gap, which it now does for these keys. Every
string in the app arrived this way, so it is not a defect this branch
introduces.
The ambiguity test was verified to fail against the pre-fix code.
handleResponse looked up the reply's prefix, and when that matched nothing
pending it fell through to "first pending command for this repeater". So a
straggler from an expired command could complete an unrelated in-flight
one, reporting one command's output as another command's result.
The service already carries a correlation token in every command and every
reply echoes it. The fallback discarded that. Now a reply that carries a
prefix is only ever matched to the prefix's owner; if there is no owner it
goes to the unmatched handling added in #528, where an expired command is
still named. Only a reply with no prefix at all, which has no correlation
token to honour, may fall back to matching by repeater.
Not reachable from today's callers: all four pass retries: 1 and the
settings refresh awaits each command, so two are never in flight to one
repeater. It becomes live the moment anything issues concurrent commands,
which any retry work would.
The two negative tests were verified to fail against the old logic and
pass against the new, so they are a real guard rather than a restatement.
The two preservation tests pass either way by design.
_registerPending is extracted so the test seam and the real send path
cannot drift apart.
Part of epic #473, stacked on #528, #529 and #531.
The timer ran timeoutMs while the message printed
(timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced
"timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired
at 4.07 s while claiming 5, which is what made the behaviour look
arbitrary rather than deterministic: the number shown was never the
number used.
The service now throws a typed RepeaterCommandTimeout carrying the window
that was actually armed, formatted to one decimal. Every existing caller
already stringifies the error, so all of them inherit an honest figure
without being touched; the CLI screen additionally renders it through a
new localized string rather than the generic error wrapper.
Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms
budget reports 28.7 rather than 29, and that two windows inside the same
second no longer collapse to the same text, which was the defect's
signature.
Part of epic #473, stacked on #528 and #529.
A repeater reply that arrived after its command's window closed hit
`if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse
and was dropped with no log and no UI. Since the command timeout can be
shorter than the app's own message-retrieval budget, that made "the
command ran but the response was never reported" the normal outcome
rather than an edge case, and it affected every repeater screen, not
just the CLI one.
RepeaterCommandService now remembers a timed-out command's prefix for two
minutes, so a reply arriving afterwards can be attributed to the request
it answers. Replies that reach no waiting command are handed to a new
onUnmatchedResponse sink and logged through appLogger. No path through
handleResponse returns without either completing a command, surfacing the
payload, or logging why it could not.
The CLI screen renders these as a distinct history entry naming the
original command and how late it was. The settings screen applies the
value if it is a `get` reply and tells the user it arrived late.
repeater_status_screen already parsed responses independently of the
service, so it had no silent-loss path to fix.
Part of epic #473. Does not change the timeout window itself (#529),
the reported duration (#531), or the stale-prefix fallback (#532).
Badge stays observers-primary; tooltip/long-press now read 'N observers . M
observations' so they reconcile with CoreScope's 'Observations (N)' feed (the
two are distinct metrics: 15 distinct observers vs 34 total sightings). Refresh
feedback moved from a bottom snackbar to an auto-dismissed top MaterialBanner,
out of the way of the composer. Part of #524.
Agent: QuietSnow (session 31eaba02)
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope
instance (default map.okimesh.org); returns observer_count or null. Never
throws, so the chat UI degrades silently to radio-only when offline or the
hash is unknown. Part of epic #524.
Agent: QuietSnow (session 31eaba02)
Children of #471 (parent stays open for AAB hardware validation, #507).
The block list was one global unscoped list on the phone, unioned onto
every radio on connect. A stale block for one of the owner's own radios
therefore rode onto every fresh/erased radio, and clearing a radio never
stuck: any other radio still holding it re-seeded the global list on
connect, which re-pushed it back.
- #505 block_store.dart: scope keys/names by connected device key (like
the app's other stores); dropLegacyGlobal() deletes the legacy
block_keys_v1/block_names_v1 (drop-and-start-fresh, owner-approved).
No global list.
- #505 block_service.dart: load() drops the legacy global and starts
empty; loadForDevice(deviceKey) swaps the in-memory set per radio and
runs the #250 self-heal. All mutating ops serialized through a Future
chain so loadForDevice and importKeys (different connector frame
handlers, both unawaited) cannot interleave and wipe each other.
- #506 meshcore_connector.dart: the device-key hook loads the connected
radio's list, so the offload union reconciles within that one radio.
Existing radio-side firmware blocks left untouched (no auto-CLEAR on
migration, owner-approved). Tests: per-radio isolation, clear-sticks
across reconnect, legacy-drop, disconnect-clears, load/import race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A transient upstream 503 (e.g. Hugging Face) hard-failed the model download and
surfaced it as an error (#229). The download requests (HEAD, single GET, range
GET) now go through sendModelDownloadWithRetry: 5xx/429 responses and network
exceptions retry with bounded exponential backoff (1..30s, <=5 attempts, honors
Retry-After, cancellable); terminal 4xx (e.g. 404) fail immediately. The retry
logic is a pure injectable top-level function, unit-tested (503-then-200,
persistent-503, 404-no-retry, network-exception, cancel).
Manual retry after a sustained failure is the existing "Download model" button
(re-invokes the download); no new UI added. Build of epic #422.
Follow-up to #457. Swept the em-dash character (U+2014) out of the test
tree (test descriptions and comments) and cleaned 8 em-dashes that
landed in lib comments via the #456 refactor after #457 merged, so the
tree is back to zero. Same rules: replaced with commas/colons/periods,
preserved the lone "no data" glyph placeholders, left non-English ARB
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Profile is now a container of capability-scoped sections (schema_version 2):
- wifi (WifiConfig) — any wifi device
- mqtt (MqttSection = region + status_interval + brokers) — observer capability
so MQTT is defined once and shared by observer / observer-repeater /
observer-companion, never redone per device. Future radio/repeater/companion/
display sections slot in alongside.
Parser reads the sectioned YAML and rejects the old flat v1 layout with a clear
message. Enumerator reads from sections but emits the SAME firmware keys, so
apply/diff/screens are unchanged. 45 config-profile tests updated + green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A max-length DM built a 170-byte frame but this BLE link only writes
ATT_MTU-3 (=169) bytes, so writeCharacteristic threw. The DM path swallowed
the throw and never resolved the message, leaving it "active" forever and
silently blocking every later DM to that contact until force-stop+reconnect.
- Size: maxContact/ChannelMessageBytes take an MTU-aware frame budget
(BLE = mtuNow-3, USB/TCP = maxFrameSize); composers pass
connector.effectiveMaxFrameSize. The channel cap now also subtracts the
"Name: " prefix so a small-MTU link can't overflow.
- DM wedge: _sendMessageDirect propagates the failure and the retry callback
is awaited, so a failed send marks the message failed and drains the
per-contact queue. Added a RESP_CODE_SENT safety timeout.
- Channel wedge: sendChannelMessage clears the stuck queue id and marks the
message failed on a failed write.
- Tests: MTU-aware caps + failed-send-does-not-wedge regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 172->176 ceiling raise made the existing "drops oversized frames and
resyncs" test's 173-byte frame a valid length, so it buffered instead of
dropping (it was red). Derive the over-max length from the ceiling so it stays
a genuine oversized frame and still exercises drop-and-resync, confirming the
higher ceiling doesn't weaken corrupt-frame recovery. Full suite: 643 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame decoder capped companion frames at 172, but the firmware's real
MAX_FRAME_SIZE is 176 (BaseSerialInterface.h, "+4 for transport codes").
Full-size caplog CHUNK frames (176 B) were silently rejected; only the final
sub-172 partial chunk survived, so a download reassembled just the last chunk
(e.g. "received 95 of 5141"). Caplog is the first feature to use full frames,
so nothing exposed this before.
- usb_serial_frame_codec.dart: usbSerialMaxPayloadLength 172 -> 176.
- serial_capture_screen.dart: erase-on-start (Start / Start&Reboot) for a clean
session ("didn't start at 0").
- 3 decoder regression tests; 113 tests total green; analyze clean.
Root cause confirmed with firmware (TopazHill): firmware streams the full
buffer correctly (wire trace 5175/5175); the client decoder dropped oversized
chunks. My earlier firmware-ring hypothesis was wrong.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the database can't open (e.g. the native sqlite library fails to load),
every read/write silently failed and the app opened to an empty, normal-looking
screen — the user thinks their history was wiped (SAFELANE §6 violation).
Probe the storage layer in main() before any store reads
(BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch
records it, and a persistent, non-dismissable banner is shown above the whole
app: history is not lost, storage is unavailable, messages are NOT being saved,
restart after fixing. Cross-platform (probe goes through drift, covers web too).
Tests: service state/latch + banner show/hide widget test. Gemini-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
formatNotificationText used GifHelper.parseGif (Giphy only) while the chat
renders via resolveGifUrl (Giphy + allowlisted Tenor CDN). An allowlisted
Tenor GIF therefore rendered inline but still dumped a raw URL into the
notification tray.
Point both at resolveGifUrl so the tray summarises exactly the set the chat
displays. Off-allowlist URLs still pass through as plain text, pinned by test.
This is the same duplicate-detection drift that caused #284; closing it at the
source rather than shipping a known inconsistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notifications summarised a GIF as "Sent a GIF" using their own hand-rolled
`^g:[A-Za-z0-9_-]+$` regex rather than GifHelper. #282 changed the payload to
`https://giphy.com/gifs/<id>`, which that regex does not match, so GIF
notifications regressed to dumping the raw URL into the tray.
Detection now goes through `GifHelper.parseGif`, matching how the adjacent
reaction case already delegates to ReactionHelper. That restores the summary
and covers every form the app can send, including replies (`@[Name] ` +
payload), which the old regex never matched even for legacy `g:<id>`.
Verified no other hand-rolled `^g:`/`^m:`/`^s:`/`^r:` payload regexes remain
outside their owning helpers, so this class of drift is closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocking yourself silently hid your own traffic with no visible cause,
and because blocks are offloaded to the radio it was pushed there and
could be pulled back by the connect-time union.
BlockService gains a selfKeyHex, injected by the connector when the self
key is learned and cleared on disconnect. Every add path refuses it
(block, importKeys, maybePromote) — importKeys specifically, so a
self-block already on the radio cannot be re-imported. setSelfKey also
heals an existing self-block and pushes REMOVE, covering the case where
the union pull lands before self-info arrives. Contacts and discovery
sheets hide the Block option for your own node.
Per Gemini review: heal on an unchanged key too (an early return would
strand a self-block introduced after the key was known), and order
setSelfKey so all in-memory state settles synchronously, keeping callers
consistent even though the connector fires it unawaited.
Adds test/services/block_service_test.dart — the first coverage for
BlockService (10 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The broker-pool GET put a single fixed 6s deadline on the entire paginated dump. A large pool (6+ brokers) streams field-by-field over ~8s and blew the deadline, so the client falsely reported the pool as not finished even though the device sent it all (the END terminator arrives after the client gave up). Re-arm an inactivity Timer on each received frame: a steadily-streaming dump never times out; only a genuine stall (no frame for the timeout window) fails. Adds a TDD test - a slow-but-steady dump, frames under the timeout but total far over it, must complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The write half of the broker editor, against RedCreek's confirmed field-at-a-time
contract. The existing 0xC0 key/value command already carries the broker keys --
no new wire codes -- so the codec comment at observer_config_client.dart:166 is
stale, not a gap.
- saveBroker(): disable a live slot first, write each changed field, write
`enabled` LAST. Stops at the first ERR/timeout and reports the failed field --
a partial save leaves the slot disabled, never live-corrupt (activation guard).
TDD: ordering + partial-failure tests.
- clearBroker(): mqtt.broker.<slot>.clear.
- getBroker(): single-slot re-GET via 14 per-field GETs (recovery / post-save
read), not the 84-frame pool dump. Password is write-only -> best-effort.
Key spellings (mqtt.broker.N.enabled / .clear, scalar field GET) are inferred
from the existing scheme; they're the one thing the joint firmware test confirms.
12/12 service tests green. UI (brokers screen + editor) next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three observer-delta fixes (per the #80 sync diagnosis / G2):
- A: settings screen gates the Observer category via context.select on the
supported flag, not a connector-wide context.watch -- the watch rebuilt the
whole settings screen on every sync frame, thrashing the UI thread.
- B: a flat Save re-reads only the flat settings (refresh includeBrokers:false),
never the 84-frame broker pool -- no BLE re-flood for a display toggle.
- C: the initial observer read is deferred until the device's channel/contact
sync settles, so it never competes with that sync for BLE.
TDD: B (no OCFG_BROKERS on a flat refresh) + C (refresh deferred while syncing,
fires once idle). Full observer suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The G2 BLE log showed the config protocol works (GETs return values; the broker
dump starts) but the firmware's broker dump stalls without BROKERS_END, so
getBrokers() times out. The client was treating that as a TOTAL failure --
discarding the 7 flat settings it read cleanly and blanking the pane.
Now the broker pool loads independently: refresh() builds + shows the flat
config even when getBrokers fails, flags brokersUnavailable, and the pane shows
the broker section as "unavailable" instead of going stale. Flat settings stay
usable. TDD red->green; full observer suite (39) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>