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>
Two findings from the owner's field report: a Channels screen full of
replacement characters, and a channel he had silenced chirping again.
An erased record is not an empty one. Erased flash reads back 0xFF and
Channel.isEmpty tests for 0x00, so a slot the radio had LOST was adopted
as a channel, rendered with U+FFFD, and then persisted over the client's
own good cached list. One missing validation gate, two symptoms.
- Channel.isErased / isUnusable: an all-0xFF record, or a name that did
not decode, is never adopted.
- A sync that sees an erased slot KEEPS the cached list, reusing the
fail-closed branch #1062 already had for unconfirmed holes.
- restoreChannelsToRadio() writes the cache back to the original slots
with their PSKs, behind a confirmed banner action.
Radio notify was keyed by slot, not identity. #544 landed after #1061 and
keyed its in-memory map by slot index; the slot-identity handler cleared
the slot's messages but not its notify mode, so a channel that moved
slots handed its mode to the new occupant and lost its own.
- _channelNotifyModes is keyed by PSK hex, matching channelNotifyModesByRadio
(#848) and the PSK-keyed history (#194).
- A reply whose slot does not resolve to a confirmed channel is dropped.
- setChannelNotify takes a Channel and resolves its CURRENT slot at send
time, so a stale handle cannot address the wrong one; unresolvable is a
visible refusal, not a silent write.
Both guards proved against planted defects: forcing isErased false turns
5 tests red including the cache overwrite, and restoring the slot-keyed
map yields Expected: null, Actual: RadioChannelNotify.off.
flutter analyze clean, 1885 tests pass. New strings English-only with
untranslated.json regenerated.
Task: meshcore-open-75d
Agent: CalmBay
Co-Authored-By: Claude Opus 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>
Upstream 1.18 lets a permitted contact send a remote-CLI command (TXT
type 3) to run on our radio. Before this, _parseContactMessage only knew
plain (0) and CLI_DATA (1), so a type-3 frame was warn-dropped as an
unknown type and never surfaced.
- meshcore_connector: accept txtTypeCliCommand in _parseContactMessage,
skip SMAZ decode for it (like CLI_DATA), and flag the Message
isCliCommand. Add @visibleForTesting parseContactMessageForTest.
- message model: new isCliCommand field threaded through the
constructor and copyWith.
- message_store: persist and read isCliCommand; skip SMAZ on read for it.
- chat_screen: label an inbound command bubble "Command for your radio"
and exclude it from the incoming-translate action.
- l10n: chat_remoteCliCommand.
test/connector/inbound_cli_command_test.dart: type-3 accepted+flagged,
type-1 isCli-not-command, plain-neither.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
F3/E3.1. Upstream 1.18 gates remote CLI (TXT type 3) on contact flag
0x10 (isRemoteCLIAllowed): the receiving radio checks it, so setting it
on a contact grants that contact permission to run CLI on this radio.
Adds the constant, Contact.remoteCliAllowed, a remoteCli param to
setContactFlags (same set/clear pattern as the telemetry flags, reading
a fresh device snapshot first), and an opt-in SwitchListTile in the
shared contact settings dialog (default off). Written with the existing
add/update-contact frame; the flags byte already rides it.
Tests: the 0x10 bit on the write-path frame (set/clear/coexist) and the
getter. Full suite green (1424); Gemini 2.5-pro review: no defects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects turned a 13-hop 3-byte-hash message into a 39-hop badge:
- log-RX ingress stored the width-stripped hop count; now stores the raw packed path-len byte, the same encoding fromFrame keeps
- the repeat merge substituted the path BYTE length into the packed field; mergePathLenRaw now compares decoded hop counts and never sees a byte length
- fromFrame read the V3 path_len field signed, so 3-byte-width values (>= 0x80) collapsed into the flood sentinel; now unsigned with 0xFF kept as the direct marker
Rendering now groups path hashes by the packet's own declared width (ChannelMessage.effectivePathHashWidth), with the radio's device-wide mode only as the legacy fallback, so the path screen, chat via-line and route-reply all agree with the badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by the pre-PR adversarial gate, which earned its keep: this is the worst instance of the exact leak the epic exists to prevent, and I had missed it.
toShareUri and toChannelShare both read the .name getter, so sharing a renamed contact put the user's private label in the payload. A share payload becomes the RECEIVED name on every device that imports it, so the label would land as another operator's ground truth and overwrite a signed advert name. Worse than sending it to our own radio, because it propagates outward instead of stopping at one device.
Both now send receivedName. The static build helpers keep their name parameter, since the device also shares its OWN name through them with no Contact involved.
KNOWN LIMIT now documented in the guard, because the near-miss matters more than the fix. T14 matches the pattern name-colon-identifier-dot-name, so it structurally CANNOT see an intra-class read, where the getter is reached as a bare name with an implicit this. That is precisely where this bug was living. Reverting the fix was confirmed to leave T14 green while T36 failed, so the limit is measured, not assumed. Widening the regex to catch a bare name is not possible without flooding on the legitimate parameter pass-through in the same file, where the static helpers forward a name argument correctly. So cross-class reads are guarded by T14, intra-class reads behaviourally by T36 and T11, and contact.dart joins the watched list for the cross-class case.
Three tests added. T36 falsified before being trusted.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1505 tests passed.
Co-Authored-By: Claude Opus 5 <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>
No preview is sent while a slot transfer runs, so neither can take the other's refusal. A final SLOT_SET reply that is not 0 is no longer read as a commit. Back during a read or save now says why it is held. Comments now cite meshcore-firmware f3d30a88 on firmware-base, which merged 3e788e2a with the contract files unchanged. The wider attribution fix is #991.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds the owner-approved parity design (docs/initiatives/standalone/975-settings-restructure/tones-screen-parity-mockups.html) against meshcore-firmware 3e788e2a:
- #986: the six event tones (DM, Channel, Room, New contact, Ack, Sent) on Buzzer and tones, one shared picker with a play button per row (0x0F/0x10/0x11). DM is one tone for every direct message.
- #970: all eight custom slots listed; the editor reads the stored tune back (0x12), checks typing with a port of firmware's rtttlProblem(), and saves in transport-sized chunks (0x0A), restarting once if displaced.
- #971: Radio tone in the channel sheet and chat menu, Default naming what it plays.
- #987: Screen (text sizes, Screen off, time zone with a phone-matched suggestion) and Battery (full point, set here, back to auto) under Node Settings, gated on caps2 0x40.
Caps2 0x10 is now TUNES (slots, channel tone, event tones, preview). Also corrects the mockup's zone count: 40 zones plus not set.
Co-Authored-By: Claude Opus 5.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>
Client-side region transport-key derivation matching firmware RegionMap/TransportKeyStore: key = first 16 bytes of SHA256("#"+name), a Region value model, and the HMAC on-air code. Known-answer tests use independent vectors. No firmware change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MeshCore's "Canada" and "USA" share 910.525 / 62.5 / SF7 / CR5 and
differ only in path hash (Canada 3-byte). Matching ignored path hash,
and Canada sorts first, so a USA radio showed "Canada" (D1 screenshot).
A preset with a path hash size now matches only a radio on that size;
presets without one match as before.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
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>
Radio Settings now takes its presets from RadioPresetService instead of
the hardcoded list, which is removed (radio_settings.dart keeps only the
LoRa enums). The picker, extracted as RadioPresetPicker for reuse on the
remote-node screen, groups presets under non-selectable region headers,
labels each MeshCore or Offband, and shows where the list came from with
a refresh button; a failed refresh stays on screen in the error colour.
Selection is tracked by preset id rather than list index, because a
refresh can change the list under an open form. Upstream presets carry
no TX power, so a preset without one matches any power and applying it
leaves TX power as it is. The form refreshes a stale list when opened.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Presets publish path hash size in bytes; firmware takes a zero-based
mode (1 byte = mode 0, 2 = 1, 3 = 2). RadioPreset.pathHashMode does
the conversion and returns null when the preset carries none or an
out-of-range size, so nothing is sent. Tests pin all three sizes and
that a published 2 never becomes mode 2.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RadioPreset carries frequency, bandwidth, SF, CR, and optional TX power,
path hash (bytes) and off-grid flag, plus its region and source.
parseUpstreamPresets reads the mirrored MeshCore list (string numbers);
parseOverlayPresets reads Offband's overlay (JSON numbers, retired
entries dropped). Malformed entries are skipped and counted; a wrong
format or a newer format_version is rejected. Upstream titles carry no
region, so upstreamRegionFor derives the picker group from the title.
Epic: #726
Agent: MagentaHawk (session 5a8922d6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three accepted findings, plus a real defect the adversarial test cases
flushed out.
A long name overflowed the chip by 405 pixels. The name comes off a
public radio channel and is attacker-controlled, so anyone could break a
message list by posting a card with a long name. The label is now
bounded and ellipsized, with an explicit width ceiling because a
WidgetSpan hands its child unbounded width.
fromChannelShare no longer scans from the first < to the last >. Two
cards in one message were read as a single span running from the first
key to the last name. That was reachable: the add dialog passes raw
pasted text straight to it. It now takes the first well-formed card via
a regex whose name class cannot cross a closing bracket.
The known-contact check is O(1) against the connector's existing
_knownContactKeys set, through a new isKnownContact. Scanning contacts
ran per chip on every notification, and the public knownContactKeys
getter copies the whole set, so neither belonged in a build method.
Card-versus-mention is now carried on the match instead of inferred from
text[m.start] == '<', which would mis-route the day a third bracketed
pattern is added.
Two findings were rejected on evidence and are justified in the PR body:
a claimed missing null terminator (firmware sets it from the frame
length itself) and a claimed greedy-regex failure (disproven by test).
Epic #610.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Models the MeshCore stock companion config export JSON so files interchange
in both directions (epic #568). Shape reverse-engineered from ten real
exports across five radios; evidence recorded on #569.
Format rules the parser enforces, none of them obvious:
- no version field exists, so validation is by shape and every top-level
section is independently optional
- public_key and private_key travel as one unit
- radio_settings mixes units: frequency kHz, bandwidth Hz, coding_rate a
bare denominator
- coordinates are JSON strings, never numbers
- channels carry no index; array position is the index
- out_path_list is one comma-separated hop hash per element, so hop count
and hash width are both recovered rather than inferred (#309)
- contact timestamps are untrusted and never sanitized
- null and empty-string out_path_list stay distinct on re-encode; both mean
no route, but real exports contain both and the difference is unexplained
Unknown top-level keys are ignored so a future stock release cannot break
the reader. Errors carry the offending JSON path for the import UI.
Test fixtures are synthetic with obviously fake keys. Validated separately
against the owner's ten real exports, which round-trip key-for-key; that
harness reads a path outside the repo and was deliberately not committed.
Two accepted findings from the standards#145 adversarial review.
Finding 4, the sharp one: the app emitted the compact <key:type:name>
card but could not parse it. A user copying a card out of a channel and
pasting it into Add by public key would have been rejected by the app
that produced it. Adds Contact.fromChannelShare and tries it after
fromShareUri in the dialog, so both real formats are accepted.
The parser splits on the FIRST TWO colons and takes the remainder as the
name, because names carry colons, spaces, emoji and CJK. It also finds a
card embedded in a longer message, which is how one actually arrives,
since people caption them.
Rendering a received card as a tappable affordance remains #610; this
only covers text pasted or scanned into the add flow. #610 is
correspondingly smaller now.
Finding 2, performance: resolveContactVerification ran an O(N) message
scan from a widget build inside a ListView. Now scans newest-first,
since a delivered message is overwhelmingly likely to be recent, and
advert-verified contacts still return on a single comparison without
touching the message list.
A lastMessageAt == epoch shortcut was written, then removed after
checking _setContactLastMessageAt: it maintains that field only for
advTypeChat, so a key-added repeater that had been messaged would have
shown the wrong badge. A cheap wrong answer is worse than a slightly
slower right one, and the rejected approach is documented in place.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer GIF button becomes a +, opening a short picker with GIF and
My contact card. Owner decision after seeing the build: one affordance
beside the text entry rather than a button per attachable thing, because
the sendable set stays small when a channel message shares a 160-byte
payload with the Sender: prefix.
Sends the COMPACT format, not the meshcore:// URI:
<{64-hex key}:{type}:{name}>
That shape was observed on the live mesh, twice, from different senders
in #test and #hamradio four weeks apart. It costs about 75 bytes against
117 for the equivalent URI, so on a 160-byte budget the difference is
airtime rather than tidiness. The URI form stays correct for a QR, a DM,
or an out-of-band paste; this is the channel idiom.
Angle brackets are stripped from an emitted name because they are the
delimiters, and a name carrying one would truncate the payload for every
parser reading it. A colon is left alone: the name is the final field,
so a correct parser splits on the first two colons and takes the rest.
Inserts into the composer rather than sending, matching the GIF picker,
so the card can be captioned and reviewed first. It appends, so a
caption already typed is not destroyed.
Parsing a received share is #610 and is not in this change, so an
inbound card still renders as raw text for now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a calm three-state indicator beside each contact name, per the
owner steer: a green check for advert-verified, a neutral outline check
for key-confirmed, a muted key for key-only. No amber and no hazard
glyph, because nothing is wrong with a key-added contact. The scale
reads as how much we know, never as how risky.
The states are not cosmetic:
- advertVerified: a signed advert arrived, so the node itself asserted
its name, type and position, and the raw packet is stored, which is
also what makes the contact re-shareable.
- keyConfirmed: a message with this contact went through. Direct
messages are encrypted with an ECDH secret derived from the contact
key, and the ACK is computed over the decrypted plaintext
(BaseChatMesh.cpp:442,451), so this is proof the holder of the
matching private key is live. It does NOT prove the person is who the
name claims.
- keyOnly: someone supplied a key and nothing has confirmed it on air.
Costs almost nothing to compute. Contact.isAdvertVerified falls out of
the epoch last_advert_timestamp that #627 already writes, and it clears
itself when a real advert rewrites the field. The advert check runs
first so only an unconfirmed contact pays for a message scan, which
keeps a long contact list cheap.
Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran
the epoch through the relative formatter and claimed a key-added contact
was last seen tens of thousands of days ago. It now reads Not heard yet.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.toShareUri and the static Contact.buildShareUri, the inverse
of #625's parser. Emitting the reference-app format is what makes an
Offband card or QR importable by a stock, non-Offband user.
buildShareUri takes raw parts rather than a Contact so the local device
can share its OWN identity, which is a public key plus a node name and
never a Contact instance.
Spaces are percent-encoded rather than emitted as '+'. Both decode to a
space, and this matches Channel.toShareUri, which the codebase already
documents as round-tripping with the reference app's QR (#161).
Tests cover the emitted parameter shape, the companion default, and
round-tripping through fromShareUri for names with spaces, emoji and
reserved characters. A raw & or = in a name would otherwise truncate or
forge query parameters, so that case is pinned explicitly.
Epic #619.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Contact.fromShareUri / isValidShareUri for the reference-app format
documented in firmware docs/qr_codes.md:
meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4>
The payload is a bare public key, not a signed advert, so the parsed
Contact is an identity stub: no path, no position, no rawPacket. A QR is
this same URI rendered visually, so scanning will share this parser.
lastSeen is deliberately the epoch rather than DateTime.now(). It maps to
the firmware's last_advert_timestamp, which the advert handler compares
with 'timestamp <= last_advert_timestamp' and treats as a replay attack
(BaseChatMesh.cpp:142-145). Stamping now would leave the contact
permanently deaf to its own adverts, since advert timestamps come from
the sender's clock and two nodes on this mesh currently advertise with
2024 clocks. A regression test pins this.
The fork's older meshcore://<raw advert hex> form returns null here, so
callers keep routing it to the existing advert import path.
Epic #619. Not yet wired to the UI: the import path needs the send half
(#627) before there is anything to add.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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)
After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND
the owner enabled the feature, the connector queries the packet hash, then
CoreScope for observer_count, and stamps it on the message (background,
best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated
switch in the #509 Experimental section (disabled until the radio supports the
capability), and a distinct cloud badge next to the radio-heard count. All
inert until the firmware PR (#611) lands. Completes the client side of #524.
Agent: QuietSnow (session 31eaba02)
Unifies the previously-duplicated send timestamps (frame builder vs outgoing
message each called now() separately) into one monotonic-per-channel value, so
every channel send has a unique (ts, channel_idx) key. That is the client-side
guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB
determinism would otherwise make two same-second messages a wrong-hash query).
Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524.
Agent: QuietSnow (session 31eaba02)
7 taps on the About version row (rolling ~3s window) reveal a neutral
'Experimental' settings category; persisted on AppSettings, survives
restart, re-hidden from a switch inside the section. Empty of toggles
for now (Fast Sync #118, CoreScope #524 land into it later).
Countdown snackbars after tap 4; already-unlocked feedback. Version-text
tap is absorbed so it counts without opening the About dialog.
Agent: QuietSnow (session 31eaba02)
resolvePathSelection returned no width and, on the override branch, a byte
count in place of a hop count. preparePathForContactSend then called
setContactPath without a width, so encodePathLen packed mode bits 00 and a
2-byte route went out as twice as many 1-byte hops (path_len 0x06 for a
6-hop 2-byte route instead of 0x46). The radio routed on wrong 1-byte
prefixes, confirmed on Bandit's 2026-08-01 log and via CoreScope. Direct
and flood were immune because neither uses the path bytes.
PathSelection now carries hashWidth and a true hop count on every branch,
using the contact's pathHashWidth as the single width authority. Both
setContactPath call sites thread it. Also addresses the override timeout
inflation half of #299 (hopCount was a byte count feeding calculateTimeout).
Gemini review found two override/history width edge cases -> split to #494.
Refs #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Ben's style rule: no em-dash character in copy, docs, or code comments
(it reads as an AI tell), and the repo is going public. Replaced the
em-dashes in code comments and English UI strings with commas, colons,
or periods, whichever reads best. User-facing strings were hand-tuned
for natural punctuation rather than a blanket comma.
Preserved the lone "no data" glyph placeholders (a standalone dash used
as a not-available indicator in status displays); those are a design
element, not prose.
Regenerated app_localizations*.dart from app_en.arb (the English
fallback for untranslated keys propagates to every locale's generated
file). Non-English ARB translations left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a DM opens the Path screen; it now shows the RF details the app
was already receiving but discarding:
- SNR: captured from the v3 contact-msg-recv frame (was skipBytes(1)).
Firmware sends (int8)(snr_dB*4), so dB = byte/4.0 (MyMesh.cpp:512) —
the old commented-out code multiplied by 4, which was 16x wrong.
- Path type: firmware sends path_len 0xFF for a direct/routed frame and
the hop count for a flood (MyMesh.cpp:545). The app collapsed 0xFF->0,
colliding "direct" with "flood, 0 hops". Capture the distinction into
Message.isFloodRoute so the Path row reads "direct (routed)" vs
"flood, N hops".
- RSSI: row wired to Message.rssi but left null — the wire byte is a
hardcoded reserved 0 today; populated once firmware ships it (#439).
Message gains snr/rssi/isFloodRoute (nullable, additive, persisted like
rxTime). 10 tests cover scaling, the 0xFF discriminator, and copyWith.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client half of the coordinated firmware+client change (firmware wadamesh#50,
verified against source).
Part A: handle push code 0x91 (pushCodeChannelsChanged) by re-polling
getChannels(force: true), so channels added/removed on the device appear in the
client without a reconnect.
Part B: read reserved1 bit0 of the V3 contact and channel message frames as an
outgoing flag. A message composed on the device (DM or channel) now renders as
sent-by-me (isOutgoing: true) instead of received. The channel self-echo guard
is bypassed for outgoing so device-composed channel messages are not dropped.
Removed the dead upstream hasPath/path-bytes branch in ChannelMessage.fromFrame
(no firmware, stock or wadamesh, appends path bytes to this frame; verified).
Backward-compatible: old firmware sends 0 (received, as today); an old client
ignores the bit.
Citadel: meshcore-open-p12
Typed model for importable device config profiles (feature #136): WifiConfig,
BrokerConfig (6 slots), region/status-interval, and a ConfigProfile container.
All fields nullable so apply engines write only the keys a profile sets.
Key names + wire encoding mirror the firmware ConfigSchema (transport/auth as
string names, wifi.pwd write-only). ConfigKeys centralizes the key strings so
the parser (#403) and per-device apply engines never hard-code them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the
setting is on and the app is foregrounded, releasing on background, on toggle
off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff),
defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reactions stored only a Map<String,int> of emoji to count, so "who
reacted" was unanswerable and MeshCore One's tap-to-see-who had nothing
to show against. This captures the reactor for every reaction, both our
own r: format and the PocketMesh / MeshCore One format.
Data layer only. The tap-a-badge-to-see-who UI is a separate follow-on;
this makes the data available and does not change any screen.
Additive by design. A new reactionSenders field (Map<String,List<String>>,
emoji -> reactor names) sits alongside the existing count map, serialised
under a new JSON key. Both maps are always written. An older build reading
a newer store ignores the unknown key and still gets correct counts from
reactions; it never hits the hard `value as int` cast that would fail the
whole message-list load (the #355 data-loss shape). Records written before
this field load with an empty sender map, so their counts survive with
names simply absent.
- ChannelMessage + Message: new reactionSenders field, constructor,
copyWith
- both stores: serialise the new key; deserialise via a shared
ReactionHelper.reactionSendersFromJson that returns empty for a missing
key and skips malformed entries rather than throwing
- applyReaction: records the reactor and dedups per reactor per emoji.
This is persistent dedup (survives restart), unlike the connector's
in-memory processed-set. A pre-existing count with no sender list is
incremented from its stored value, not recomputed from the partial
list, so old counts are preserved.
- connector: threads the reactor name into all reaction paths. Channel
uses the frame sender; a room resolves the author via
fourByteRoomContactKey; an outgoing reaction is attributed to self.
- pending queue: retry now hands the stored reactor to its callback
Tests: reactor capture, two-reactor count, same-reactor no-double-count,
pre-#383 count preservation, and JSON round-trip + backward-compat +
malformed-entry handling for the new field. Full suite 604 passing.
Epic #376.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner hardware test on an S25 FE and Windows turned up five issues. Three of
them shared one root cause.
**Map: always show names.** New mapAlwaysShowNames setting, toggled from the
map panel. Names were shown only past zoom 14; the override wins at any zoom.
Evaluated at build rather than only on camera movement, so flipping the switch
repaints immediately instead of waiting for the next pan.
**Panel footer on every screen.** Disconnect and Settings now also appear
inside a channel. The footer is app-level, so it belongs anywhere the panel
is, not just the primary views.
**Back button (three reported symptoms, one cause).** Channels, Contacts and
Map each wrapped themselves in `PopScope(canPop: !isConnected)`, so while
connected the system back press was swallowed whole. That explains all three:
an open drawer would not close, Android would not background the app, and on
Windows the pinned layout has no Scaffold drawer, so the app bar auto-implied
a back arrow whose press PopScope then discarded - a button that visibly did
nothing.
AppShell now owns back handling for every screen that uses it, in order:
close an open drawer, else pop the route (a pushed chat returns to its list),
else hand back to the OS via SystemNavigator.pop() so Android returns to the
home screen or the previous app. The three per-screen PopScope wrappers are
removed. AppShell becomes stateful to hold the scaffold key this needs.
flutter analyze clean, dart format clean, 469 tests pass. Not yet re-tested on
hardware.
The firmware path-len byte is packed and self-describing (src/Packet.h:79-84):
high 2 bits = hash size - 1, low 6 bits = hash COUNT (hops), byte length =
count * size. The app treated the low 6 bits as a byte count and ignored the
width, on the strength of a comment asserting the high bits were "not reliably
populated". Both halves of that comment were wrong.
Receive: Contact.fromFrame read `count` bytes where firmware meant `count`
hops, so at 2-byte width it kept HALF of every path and discarded the rest.
DAYTON VA's 2-hop route became a 2-byte fragment, which is why every repeater
login in Bandit's capture timed out.
Send: buildUpdateContactPathFrame wrote the count raw, leaving mode bits 00.
That tells the radio "1-byte hashes" while handing it 2-byte hash data, so it
routed to nodes never on the route. This is the likelier cause of the observed
"routes via c6 and 5c separately" behaviour.
Also fixes a third unit inconsistency: setContactPath wrote customPath.length
(bytes) into pathLength (hops) after every path set.
Contact now carries pathHashWidth per path, so a stored path can never be
re-sliced at a width it was not captured at. realHopCount() is removed rather
than adjusted: it divided an already-correct hop count, halving it at 2-byte
width, and every call site was compensating for a bug that no longer exists.
Migration (Ben's call, option A): records written before this change have no
pathHashWidth and hold truncated bytes that cannot be recovered by
reinterpretation, so their path is dropped and the contact reverts to flood
until the radio re-supplies it. Logged, not silent.
Tests asserted the old semantics (expect(realHopCount(2, 2), 1)) and were
rewritten against firmware truth rather than adjusted to keep passing. Added
send-side encoding coverage, which did not exist. 456 tests pass.
Refs #240, #279, #299
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every received DM/channel message now logs arrival wall-clock vs the
sender-claimed payload timestamp (delta + TIME ANOMALY marker beyond
24h), routed directly to AppDebugLogService so the rotating on-disk
trail is unconditional. Models persist a nullable rxTime set at every
live ingest construction site; legacy stored records stay null.
Diagnostic child of #280.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds ChannelNotifyMode { all, mentionsOnly, off } and a channelNotifyModes
map keyed by PSK identity via AppSettings.channelNotifyKey, mirroring the
psk_ marker scheme ChannelMessageStore uses for history (#194). Keying by
PSK rather than display name means the setting survives a channel rename
and does not bleed across a slot reassignment (#193).
Legacy muted_channels stays readable and keeps being written:
AppSettingsService.channelNotifyMode falls back to it by name so a channel
muted before this feature still reports off, and setChannelNotifyMode keeps
it in sync so rolling back to a build without notify modes still honours an
Off channel. Name cannot be resolved to a PSK at the data layer, so the
migration is a read-through fallback rather than an upfront pass.
The notification gate and Channels UI still use the legacy isChannelMuted
path; #261 and #262 switch them over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware path-len byte packs a hash-mode hint in the high 2 bits and the
path byte-length in the low 6. Contact.fromFrame stored it raw, so a direct
node at 2-byte mode (0x40) read as pathLength 64: "64 hops" in the UI, a ~195s
ACK timeout, and up to 64 junk bytes pulled into contact.path that broke
repeater status/telemetry/neighbors until the path was cleared (#222).
- contact.dart: decode via pathHopCount (& 0x3F); keep 0xFF as the flood
sentinel; use the decoded length for safePathLen so path bytes stay clean.
- chat_screen _currentPathLabel: show realHopCount(pathLength, pathHashByteWidth)
so 2/3-byte nodes read the correct hop count; 0 -> Direct.
- Regression test for 0x40/0x00/0x03/0x44/0xFF; updated two model_changes tests
that codified the old raw mapping.
Part of #222 (stays open for on-hardware validation by the reporter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.
Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offband 0xC1 GPS query: 1-byte request -> RESP 0xC1 ASCII reply
(enabled/detected/active/fix/baud/lat/lon/alt_cm/sats/time), parsed into
OffbandGpsStatus (lat/lon /1e6). New GPS-status section in the Location dialog
(live-fix vs stored banner + coords/sats/alt/time). Validated against RedCreek's
feat/gps-state-query test firmware.
#140: the per-minute GPS poll now uses the lightweight 0xC1 query instead of
CMD_APP_START (which re-walked channels + re-drained messages = the BLE re-init
flood); a live fix updates self-lat/lon. Validated on b42 (APP_START ~1/min ->
~0, 0xC1 polls clean every 60s, channel re-walk gone).
Gemini (fix-then-ship): atomic completer-claim in _handleOffbandGps to shrink
the stale-reply race; full elimination needs a protocol sequence id, but the
poll shares in-flight via the re-entrancy guard and the query button is disabled
during a request, so concurrent requests don't occur in practice. The hardcoded
snackbars Gemini flagged in _editLocation are pre-existing l10n debt, not
introduced here; all new strings are localized.
Closes#135Closes#140
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the additive OCFG_BROKERS dump fields from firmware #172/#173
(backward-compatible — absent fields read as today's enabled/disabled):
- BrokerRuntimeState (`state`) + BrokerLastError (`last_error`) + a pure
BrokerConfig.status mapping -> Connected / Connecting / Failed (reason) /
Held (no clock|low heap) / Enabled / Disabled; held shown neutral, not error.
- Brokers list shows the real status line + colour instead of just "enabled".
- Editor shows jwt_owner / iata_override resolved defaults (`jwt_owner_resolved`,
`iata_resolved`) as greyed placeholders when the raw key is blank; only writes
the raw key when the user enters a value (blank stays the source of truth).
Sourced from the pool dump (getBrokers); getBroker's per-field GET list is
unchanged pending the firmware GET-ability answer on #173. Unit-tested mapping,
parsing, and backward-compat (9 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware build can ACK a broker enable/disable as success without applying it
(HV4; meshcore-firmware#179). The quick toggle and the editor save previously
trusted the ACK and showed a success snackbar, so an ack-but-no-op looked like
it worked.
Both paths now SET, wait a short settle, re-read the slot, and report what the
device ACTUALLY did:
- applied -> confirm
- notApplied -> warn "possible firmware issue"; the editor stays and re-seeds to
the device's true state instead of popping a false success
- unverified -> re-read failed (e.g. an enable that rebooted the device)
Every device is treated identically: the UI reflects the re-read, never assuming
a change took that the device didn't confirm.
- BrokerConfig.classifyApply pure classifier + BrokerApplyOutcome (unit-tested)
- ObserverConfigService.applySettleDelay
- wired into mqtt_brokers_screen quick toggle + broker_editor_screen save
- widget test for the ACK-but-not-applied path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware feedback: JWT owner defaults to the device pubkey and IATA override
is optional by definition, so gating a save on them is wrong -- they have
under-the-hood firmware defaults.
BrokerConfig.enableError now checks only the structural fields with no default:
URL present, port in range. The JWT field checks are gone; the firmware enforces
JWT completeness with its defaults, and a genuinely-bad enable surfaces the
firmware's reason via the failure messaging added earlier. The editor's pre-save
check and the list's quick Enable both follow.
Tests updated to the new contract (a JWT broker with blank owner/audience now
enables; a structurally-complete quick Enable confirms). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>