fix/1140-apple-app-names
main
dev
feat/1109-macos-signing
feat/2069-indicator-tags
fix/1128-channel-key-history
feat/1129-sponsor-prompt
fix/1118-factory-reset-delete-on-send
fix/1122-notify-logging
fix/1896-client-slot0-public
feat/1054-factory-reset
fix/1111-osm-identity
feat/523-land-indicators
feat/1108-macos-artifact
feat/1014-ios-app-review
feat/1013-ios-signed-lane
feat/1068-radio-channel-notify
docs/1091-epic-chain
docs/1053-play-automation
fix/1065-vercode-floors
feat/1012-ios-signing-toolchain
docs/958-ios-appstore-plan
fix/1033-scan-location-message
feat/693-offline-app-settings
fix/1009-release-linux-2404
release/1.7.0
epic/870-console-gating
feat/759-fake-radio-integration
feat/758-fake-radio-ci
feat/757-fake-radio-behaviors
feat/756-fake-radio-core
epic/870-remote-cli-permission
feat/842-sync-is-truth
feat/975-settings-restructure
feat/966-clear-stale-history
feat/965-stale-blob-sweep
docs/962-epic-chain
docs/962-reclamation-plan
fix/943-952-storage-migration
feat/940-isolation-integration
feat/939-scope-key-lifecycle
feat/938-blocked-per-radio
feat/937-storage-service-migration
feat/936-scoping-harness
docs/853-isolation-plan
fix/754-discovery-per-radio
feat/920-message-rows
chore/904-docs-proof
feat/919-blob-isolate
fix/927-ci-spike-flag
feat/917-storage-save-path
chore/904-docs-only-ci
docs/857-interop-plan
feat/844-per-radio-notify
fix/812-land-814-815-on-dev
epic/816-integration
epic/814-discover-regions
epic/813-region-protocol
epic/815-channel-scope
docs/812-region-scoping-plan
release/1.6.0
fix/793-log-composer-budget
feat/703-forced-resync
docs/755-plan-flash-fix
docs/755-fake-radio-plan
docs/750-contact-naming-plan
feat/702-lossless-serial
feat/668-contact-sync-guard
feat/610-contact-card-receive
fix/717-linux-web-mtu
feat/638-top-toast
fix/589-late-reply-log-only
fix/663-triage-internal-actors
fix/652-release-prerelease-flag
feat/619-contact-identity-uri
fix/581-ack-adoption
epic/568-export-import
fix/580-caplog-partial
release/1.5.0-beta.1
task/501-deviceui-logging
chore/542-assets2
chore/542-assets
docs/519-claudemd-web
release/1.4.0
feat/483-button-buzzer-ui
fix/497-mention-verbatim
fix/279-send-width
docs/478-docs-home-research
release/1.3.0
fix/443-battery-optimization-warning
fix/447-close-labeler
fix/445-windows-sqlite3-native-assets
feat/429-companion-sync
fix/395-dm-channel-send-wedge
feat/427-localize-log-share
feat/393-app-log-share
fix/share-icon-android
fix/389-back-nav
feat/385-storage-health
release/1.2.2
feat/367-store-consolidation
chore/365-cut-1.2.1
fix/363-pin-db-path
feat/351-contact-settings-menu
feat/349-window-geometry
feat/355-migration-merge
integration/290-306-335
release/1.1.2-rc.3-allplatforms
fix/309-path-len-decode
chore/298-path-logging
fix/285-ingest-timelog
fix/252-block-outgoing-dm
feat/64-observer-config
feat/batch1-ux
chore/offband-rebrand
v1.8.0
v1.7.0
v1.6.0
v1.5.0-beta.1
v1.4.0
v1.3.0
v1.2.2
v1.2.1
v1.2.0
v1.1.2-rc.3-allplatforms
v1.1.2-rc.3
v1.1.2-rc.2
v1.0.0
observer-g2-rc3
observer-g2-rc4
observer-g2-rc5
observer-g2-rc6
observer-g2-rc7
observer-g2-rc8
observer-g2-rc9
preview-1.8.0-1
v1.1.0-rc.1
v1.1.0-rc.2
v1.1.0-rc.3
v1.1.2-beta.1
v1.1.2-beta.2
v1.1.2-beta.3
v1.1.2-ble-test
v1.1.2-queuediag
v1.1.2-rc.1
v1.1.2-rc.1-b45-nearbyrep
v1.1.2-rc.1-b46-pathhash
v1.1.2-rc.1-b47-tracewidth
v1.1.2-rc.1-b48-repeatertrace
v1.1.2-rc.1-b49-maptrace
v1.1.2-rc.1-b50-tracefix
v1.1.2-rc.1-b51-originstrip
v1.1.2-rc.1-b52-channelqr
v1.1.2-rc.1-b53-viarepeater
v1.1.2-rc.1-b54-rxfed
v1.1.2-rc.1-b55-topology
v1.1.2-rc.1-b57-routeux
v1.1.2-rc.1-b58-toporeadout
v1.1.2-rc.3-b59-pathlen
${ noResults }
395 Commits (ee01c1917526eeec8c511693179a87c314387bec)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
e592eed80b |
fix(#1117): restore to radio places by identity, reports what landed
Restore to radio (#1060) replayed each cached channel into its remembered slot, in place, and reported how many frames it sent. On the firmware shipping next that is refused for every erased slot (an erased record reads as occupied, #1865), for slot 0 unless it is Public, and for Public anywhere else (#1896). It would still have said it restored them. planChannelRestore (lib/helpers/channel_restore_plan.dart) now places by identity: - a cached channel whose key is already live on the radio is left out - Public goes to slot 0 - any other channel goes to its remembered slot when that slot is free or erased and not slot 0, otherwise to the lowest free slot - a live channel is never a destination, so nothing is overwritten - an erased slot is cleared before it is written Every write goes through _writeChannelSlot and waits for the radio's OK or ERR. The result is what the radio confirmed. The screen says "The radio confirmed all N channel(s)", or "confirmed X of Y. Not restored: ...", persistently. Waiting for each answer also paces the writes by the radio itself, which replaces the fixed 10 ms gap. The comment claiming a confirmed count was impossible is gone. The confirm dialog lists only the channels the radio is missing, and no longer warns that it replaces what the slots hold, because it doesn't. The #1060 restore tests are rewritten to the new contract, keeping what they protected (no erased entry written back, nothing cached is refused, the key travels with the channel, writes are paced), and adding: slot 0 for Public only, clear-before-write, nothing live rewritten or overwritten, refusals reported by name. Mutation-checked with this commit and the previous one: not skipping slot 0 fails 5 tests; never clearing first fails 1; counting sends as confirmed fails 1; allowing a Public delete fails 2; a one-# hashtag rule fails 1. Full suite 2034 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
5d563906c6 |
fix(#1025): a channel write never starts its own re-read
Second Gemini round on Epic #1021, findings verified against the code. Kept, fixed: - A writer's re-read called _syncChannels directly, so with two writes overlapping, the first to finish started a sync while the second still waited on the same reply queue. The trigger was restoreChannelsToRadio, the one write path the UI did not gate. Writers now only request the re-read; the last write to finish runs it, once. Nested writes (an edit's clear and add) and batches re-read once, at the end. - Restore to radio refuses with the busy message while the table is busy, like every other channel write. With that, no write path can overlap a sync. Justified, not changed: - "A write slips in between a batch ending and its deferred sync starting": calling an async function runs synchronously up to its first await, and _syncChannels sets _isSyncingChannels before any await, so no tap can land in between. - "A disconnect leaves the write counter stuck": every write decrements in finally, and _writeChannelSlot returns within the 5 s reply timeout even after the queue is cleared. A sync deferred meanwhile runs when that write ends. - "A deferred sync is dropped if a sync is already running": reachable only when a write overlaps a running sync, which no write path allows now. Mutation-checked: a writer starting its own re-read fails the new overlapping-writes test. Full suite 1963 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
085843f758 |
fix(#1025): no sync shares the reply queue with a channel write
Gemini review findings on Epic #1021, after verifying each against the code. Kept, fixed: - A sync started while a channel write waits for its reply shares the ordered reply queue with it, so the sync's empty-slot ERR could be taken as the write's answer. The trigger is real even though the review named a path that doesn't exist: pull-to-refresh, the sync button, the 400 ms channel-changed debounce, and dev's 3 s failed-sync retry all call getChannels at any time. getChannels now defers while a write is in flight, and the last write to finish runs it once. The writers' own re-reads call _syncChannels directly. - Config import and community delete hold the guard across their whole loop (batchChannelWrites), so a deferred sync cannot start between two writes. - A key change whose clear lands but whose add fails used to report "did not confirm the change", understating that the channel is now gone. It returns ChannelWrite.clearedNotAdded, with a message saying the slot is empty and to add the channel again. Justified, not changed: - A reply arriving after the 5 s timeout, once the waiter has been dropped, shifts the queue by one. Keeping the waiter instead would shift it permanently whenever a reply is truly lost. Firmware answers CMD_SET_CHANNEL synchronously, far inside 5 s, and a disconnect clears the queue, so dropping is the smaller risk. - Claimed crash on removeAt(0) with an empty queue: both handlers check isEmpty first. Mutation-checked: removing the deferral fails 2 tests; returning the add's own result fails 1. Full suite 1962 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
c8de4203f7 |
fix(#1026): wait for the radio's reply before a channel write counts
setChannel sent CMD_SET_CHANNEL and moved on without the radio's OK or ERR, then cleared the slot's message history on the strength of having sent it. Firmware with OffbandMesh/meshcore-firmware#1864 refuses a different channel over an occupied slot with ERR_CODE_BAD_STATE and keeps that channel intact, so the client would have deleted the history of a channel the radio still holds. Connector: - setChannel, deleteChannel and the new editChannel wait for the reply, using the existing generic-ack queue (each pending entry can now carry a completer), and return ChannelWrite: ok, occupied (BAD_STATE), rejected, or noReply (timeout, send failure, or not connected). - History is cleared only after an OK. A refusal or no reply keeps it. - A timed-out waiter leaves the queue, so a late or lost reply cannot be matched to the next command. - isChannelTableBusy covers an unanswered write as well as a sync. Without that, a second add made while the first is waiting would pick the same slot, because the list does not show the first one yet. - editChannel does clear-then-add for a new key. #1864 firmware refuses a new key written in place (owner ruling on that epic: changing a key is clear-then-add), so the edit dialog would have broken once it shipped. A rename is still a single write. Callers: - Every add, edit and delete path waits for the result and shows its success toast only after an OK; otherwise a persistent message says what happened. The two places the channels screen cleared history itself now do it only after an OK. - Edit and delete refuse, with the busy message, while the table is busy. - Config import and community delete write one channel at a time and re-read the list once at the end. A re-read after each write would put the next write inside a sync, where its reply cannot be told from the sync's. Import counts only confirmed channels; refused ones are listed as skipped. - Creating a community: the dialog closes before the write, so the wait cannot invite a second tap, and a refused channel's message is no longer replaced by the "created" toast (a #1022 regression, since a newer toast replaces the visible one). Tests: 12 connector tests driving synthetic OK / BAD_STATE / NOT_FOUND / no reply. Mutation-checked: clearing before the reply fails 3, a new key written in place fails 1, and a timed-out waiter left queued fails 1. Full suite 1793 pass, analyze clean. The refusal path cannot be exercised on a real radio until #1864 ships; the OK path can be tested on hardware now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
3364895a9f |
feat(#523): display mode, status LED and rotation over 0xC5 0x1A/0x1B
Lands the #523 indicator controls, separated from the factory reset (#1057) that happened to share their branch. #1057 is destructive, untested and the owner did not ask for it, so it stays behind rather than riding along. Rotation is 0xC5 0x1A GET / 0x1B SET, gated on TAG_DISPLAY_ROTATION in the 0x1C advert, with no caps-bit fallback and no probing. It is NOT 0x18/0x19: those are per-channel channel notify, and an earlier build that put rotation there corrupted silently, because the firmware's 0x19 handler validates a channel-notify mode and nothing else. The hazard commit 89f1768e is not in this history at all; this is the post-fix state only. Display mode and the status LED read device-info bytes 86 and 85. Nothing reads byte 87, which contract section 2 retired permanently. Provenance, since the route matters: cherry-picked ea09d2f3 with -m 1. That commit is the merge of the #523 work into the #1095 advert work, and the rotation fix lives in its merge resolution rather than in a commit of its own, so its first-parent diff is exactly what #523 adds on top of what dev already carries. The result is 956 insertions across 28 files, matching ea09d2f3's own stat, and offband_indicators.dart, indicators_view.dart and indicators_connector_test.dart are byte-identical to the old branch tip. Adversarial review (gemini-2.5-pro) found a crash, fixed here: the rotation control force-unwrapped displayRotation, but the advert tag that ENABLES the control arrives before the rotation GET reply that supplies the value, so the pane threw on any slow link. It now takes an empty selection, matching the display-mode control beside it, which was already null-safe. Proved by probe: restoring the unwrap yields "Null check operator used on a null value" and turns 2 of the new widget tests red. The review's second finding, a read reply overwriting a newer confirmed write, is real but deliberately NOT fixed here. The obvious guard is to drop read replies while a write is outstanding, and _sendIndicatorSet sets that flag with no timeout and no cleanup, unlike _awaitDeviceUi which clears it in a finally. That guard would wedge rotation reads for the rest of the session after any SET that went unanswered, which is worse than the stale value it prevents. Giving _sendIndicatorSet the same timeout discipline is the real fix and is follow-up work, not something to change minutes before a cut. flutter analyze clean, 1945 tests pass and 1 skipped, including 25 indicators connector tests and 4 new widget tests. Not hardware-tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
6 days ago |
|
|
bd0744e472 |
fix(#1060): drop erased records on load, and pace the restore writes
Adversarial review (gemini-2.5-pro) on the push candidate raised five findings. Two were real and are fixed here; the other three are justified in the PR body. Storage is an untrusted source too. The guards already on this branch stop an erased (all-0xFF) record being adopted from the WIRE, but a build from before they existed could have persisted one, and loadCachedChannels applied no filter. _cachedChannels is a fallback identity source for _findChannelByIndex and is re-saved by the unread paths, so such a record comes back as the channel for live traffic and outlives every radio. It is dropped at the load boundary, which protects every consumer at once. It carries nothing recoverable: the name is U+FFFD and the PSK is all-ones. restoreChannelsToRadio sent its writes in an unpaced loop. On BLE sendFrame prefers write-without-response, so its await returns when the frame reaches the OS and not when the radio has taken it: a multi-channel restore could overrun the radio and lose channels with no error raised anywhere. The USB branch of sendFrame already spaces its own frames for exactly this reason. Same 10ms gap between consecutive channel writes. The restore result is documented as written and NOT confirmed. Counting confirmations after the re-sync was tried and reverted: getChannels returns once the first request is away rather than once the sync has finished, so it would have read a list the radio had not answered for and reported zero every time. The erased-slot banner is re-derived on every sync and stays up if writes did not land, so it remains the authoritative signal. Both new guards proved against planted defects rather than assumed: removing the load filter turns 2 tests red, and removing the pacing yields "Expected: a value greater than or equal to <20>, Actual: <0>". flutter analyze clean, 1896 tests pass, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 week ago |
|
|
f5fd17f6ff |
Merge remote-tracking branch 'origin/dev' into feat/1068-radio-channel-notify
# Conflicts: # lib/connector/meshcore_connector.dart |
1 week ago |
|
|
383ed5f9ee |
fix(#1060): erased slots never overwrite the channel list, and radio notify follows the channel
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> |
1 week ago |
|
|
655e396183 |
feat(#1100): set and surface the radio's BLE pairing PIN (cmd 37)
Stock-parity gap: the companion radio persists a pin set over CMD_SET_DEVICE_PIN (37) and reports the configured value in device info bytes 4..7, but the client exposed neither. - cmdSetDevicePin + buildSetDevicePinFrame ([cmd][pin x4 LE]). - parseDevicePin reads the device-info field; deviceBlePin getter (0 = firmware build default, null until reported), nulled on each new connection handshake; setDevicePin validates with firmware's own rule (0 or 100000-999999) and sends like the other config writes. - Settings tile in the device Actions card, pin shown in PLAIN text (owner ruling 2026-11-06: no masking, parity with other mesh clients). Edit dialog with inline validation and Reset to default; persistent re-pair notice on success, persistent error when the send fails. English strings only; untranslated.json regenerated. Gemini gate on the diff: fixed the unsaveable prefill of CLI-set out-of-range pins, the failed-send success lie, and a controller leak; double-tap idempotence and the short-frame fallback justified in the PR. Negative evidence: mutating the encoding, the parse offset, or the validation fails 4 of the 7 new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
ad319721ca |
fix(#1063): close the adversarial-review findings on the 1061 stack
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings: - Held channel message frames are now keyed per radio: verifying the unbounded-growth finding exposed that frames held from radio X would have flushed into radio Y's conversations after a radio switch. A parked radio's frames flush when that radio next completes a sync. No cap is added: held frames are popped messages, dropping one is guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame). - _loadChannelMessages merges messages that arrived during the disk read instead of clobbering the list, so the slot-identity reload cannot hide a message the held-frame flush just filed (disk was never at risk: saves merge by identity, #343). - sendChannelMessage refuses the write for a slot this connection has not confirmed (the persisted copy would fall back to the legacy slot-index key and be orphaned), and the chat screen surfaces a persistent error at every send path instead of greying anything. New English UI string; untranslated.json regenerated (#1063). Three integration tests cover the fixes (#1064); with the guards behaviorally disabled exactly those three fail (+3 -3). Full suite 1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
06dd15fa9b |
test(#1064): end-to-end slot-shuffle scenarios + slot-level ingress hold
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063): the #1060 bug-validation scenario (wipe + re-import with shuffled slots: list matches device, no misfiled or mis-rendered message, notify modes evaluate against the right PSKs), a manual device-side reorder (history follows the channel identity to its new slot), and a flaky sync mid-reconnect (held traffic files only after a confirming sync). Writing them exposed one gap: _flushHeldChannelFrames documented that a frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier, so after a partial sync such a message would file by raw index against an unknown identity and persist under the legacy index key. _handleIncomingChannelMessage now re-holds it until a sync confirms the slot. Test plumbing: channelNotifyProbeForTest observes the notify decision (slot, label, PSK, mode) without reaching the NotificationService singleton; wireMessageStoreForTest wires the message store the way the SELF_INFO handshake does. Issue: #1064 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
eb76acd323 |
fix(#1063): keep per-slot message state from crossing channel identities
Persisted channel history has been PSK-keyed since #194; the remnants that could still cross-wire on a slot reassignment were in-memory and legacy. Epic B of #1061, stacked on #1062: - _completeChannelSync's identity diff now drops the slot's in-memory conversation and reloads it from the store, which reads the NEW identity's own history through the (now fail-closed) resolver. - setChannel's reassignment clear purges only the LEGACY slot-index keys (new clearLegacySlotHistory), so the outgoing occupant's PSK-keyed history survives and comes back if the channel is re-added. True deletes (deleteChannel) keep the full both-key clear. - A WRITE that falls back to the slot-index key now logs an error with the orphaning consequence spelled out; post-#1062 live ingress cannot reach it. - The #193-era comment claiming history is slot-keyed is corrected and the setChannel/resolver interplay documented. Tests: 2 connector tests (in-memory drop on identity change, kept on same identity) + 5 store tests (legacy purge, PSK history survival, no adoption onto the new occupant, true delete). Issue: #1063 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
4c259d9873 |
docs(#1097): record the verified firmware mode encoding and why 0x18/0x19 corrupted silently
Read from firmware src/helpers/ChannelNotify.h on epic/606-channel-notify rather than taken from Agent Mail: kChanNotifyUnset = 0, All = 1, Mentions = 2, Off = 3, Count = 4. The client enum already matched byte for byte, so nothing changes in behaviour; this records that it was checked against the source, since taking numbers from mail is what caused the collision in the first place. The contract names the modes All / Self / None plus 'follow the device setting'. That is the same encoding in different words: Self is mentions, follow is unset. Worth saying so explicitly, because two vocabularies for one wire byte is how a later session talks itself into a renumber. Also records WHY the rotation collision was silent rather than refused: firmware validates a SET with 'stored < kChanNotifyCount' and nothing else, so every rotation value from 0 to 3 passes as a valid notify mode and writes to the channel at that slot index. Slot 0 is Public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 week ago |
|
|
5dc968ee5b |
feat(#1095): the 0xC5 0x1C capability advert, and nothing at offset 87
Realigns the client to the merged contract. Firmware docs/offband_info_frame_contract.md revision 2 and OffbandConfigProtocol.h, both on firmware-base; the header is the registry and wins on any disagreement. Wire numbers claimed by firmware PR #2000, merged 28785ebf at 2026-11-04 08:34:29Z. Nothing here was built before that merge, per contract section 2. Three features each computed "the next free byte" of upstream's device-info frame as 87 and each was right in isolation. A collision at a frame offset is silent misparsing, not a refused command: everything compiles, every test passes, and it only shows up on hardware as one feature appearing broken. #1095 the advert client #1096 offset 87 removed, with a guard #1097 channel notify re-gated One request on connect, zero payload, after device-info has landed and so well past MTU negotiation. The client asks and the device never pushes: _att_mtu is 23 until onMTUChange fires, so a pushed advert would be truncated to a 20-byte payload as the normal case. A tag's presence IS the capability. No bitmap, no probing each feature's sub-code. len 0 means supported with nothing to report; an absent tag means the board lacks the feature and is neither an error nor a zero value. Unknown tags are kept and skipped by len, because a client built tonight has to stay correct against firmware emitting tags it has never heard of. TAG_CONTINUE is honoured, bounded at 8 rounds and refused a cursor that does not advance: an unbounded request loop driven by device data is worse than a truncated capability list. [0xC5][0x7F][0x05] to the zero-payload request means the radio predates the advert, so capabilities fall back to the device-info offsets that shipped before it. That reason is overloaded on firmware (malformed payload on a known sub-code returns it too), which is exactly why the request carries no payload. The refusal names no sub-code, so it is claimed only while an advert request is outstanding AND no write is: a write owns its own refusal. Channel notify gating is PRECEDENCE, not OR, and this part needed a ruling. Both caps2 0x80 and TAG_CHAN_NOTIFY are merged claims and the contract does not say which wins. Both single-source readings break on a real radio: tag-only takes the control away from 4e5a6c85, already flashed on the T1000-E, which has the bit and predates 0x1C; bit-only loses the control when the bit is retired under contract section 4. So an advert, once it arrives, is authoritative and the bit is ignored, and with no advert the bit answers, which is what shipped before. Raised with FuchsiaCreek before coding rather than worked around (Agent Mail 1449); one expression to invert if the ruling differs. Rotation and ACK capacity gain their gates here but not their features: #1098 and #1099 follow. Rotation is tag-gated with no caps-bit fallback, deliberately -- 0x1A/0x1B were claimed in the same merge as the advert, so no firmware can rotate but not advertise -- and explicitly NOT on caps2 0x04 INDICATORS, which means something else. parseOffbandCaps3, offbandCaps3Offset and offbandCaps3MinFrameLength are gone rather than left unused, so a later change cannot pick them back up. The replacement is behavioural: a device-info frame with every bit set at offset 87 must produce identical capability answers to one that has no byte 87 at all, the raw caps byte included. The getter list alone was a hole -- a leak into a bit with no getter yet moved nothing the test could see -- found by planting such a read and watching the test pass, then comparing the raw byte as well. Each new guard was made to fail before being trusted: the unknown-tag skip (parser dropping unknown tags, 2 red), the precedence rule (gate rewritten as an OR, 1 red), the offset-87 guard (a read reintroduced, 1 red after the strengthening above). flutter analyze clean. 1869 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
1 week ago |
|
|
21fa162d4a |
Merge remote-tracking branch 'origin/feat/1063-message-state-hardening' into feat/1068-radio-channel-notify
# Conflicts: # lib/connector/meshcore_connector.dart |
1 week ago |
|
|
1169b365d5 |
feat(#1068): read radio modes on connect, and the Radio sound control
Epics B3 and C of #544, so there is something testable on hardware. B3 reads the radio's mode for every reported slot, hooked to _cleanupChannelSync(completed: true). Deliberately only on completed: the failure branch restores a CACHED channel list, and addressing a slot from a cache is the guessed-slot error this must not make. When #1061 lands its stricter confirmed-list barrier this moves onto it. C adds 'Radio sound' as its OWN control beside the phone's notifications, on the Channels sheet and the channel chat menu, gated on caps2 0x80. The existing control is relabelled 'Phone notifications' so neither is mistaken for the other. Neither reads the other: that independence is pinned by the A2 tests. C3, the owner's call: the row shows what the channel will ACTUALLY do, not the stored byte. A set mode decides for itself; an unset one reads 'Follows the radio: <scope>'; and a device scope of None shows as silent even on a channel set to All, because telling a user with a muted radio that a channel is on 'All messages' is the confusion this feature exists to end. Null composes to a reading state, never to an invented mode. Proved that check can fail: removing the None-override branch fails the test that asserts it. Restored. Strings are English-only with untranslated.json regenerated, per the l10n rule. flutter analyze clean; full suite 1809 passed. |
1 week ago |
|
|
12154c61f2 |
fix(#1063): close the adversarial-review findings on the 1061 stack
Gemini gate (gemini-2.5-pro) on the pre-push stack, four findings: - Held channel message frames are now keyed per radio: verifying the unbounded-growth finding exposed that frames held from radio X would have flushed into radio Y's conversations after a radio switch. A parked radio's frames flush when that radio next completes a sync. No cap is added: held frames are popped messages, dropping one is guaranteed loss, and growth is traffic-bounded (<=172 bytes/frame). - _loadChannelMessages merges messages that arrived during the disk read instead of clobbering the list, so the slot-identity reload cannot hide a message the held-frame flush just filed (disk was never at risk: saves merge by identity, #343). - sendChannelMessage refuses the write for a slot this connection has not confirmed (the persisted copy would fall back to the legacy slot-index key and be orphaned), and the chat screen surfaces a persistent error at every send path instead of greying anything. New English UI string; untranslated.json regenerated (#1063). Three integration tests cover the fixes (#1064); with the guards behaviorally disabled exactly those three fail (+3 -3). Full suite 1807 passed / 1 skipped. OUTCOMES.md records the gate and dispositions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
3d40da28f2 |
test(#1064): end-to-end slot-shuffle scenarios + slot-level ingress hold
Integration tests for Feature #1061 across Epics A (#1062) and B (#1063): the #1060 bug-validation scenario (wipe + re-import with shuffled slots: list matches device, no misfiled or mis-rendered message, notify modes evaluate against the right PSKs), a manual device-side reorder (history follows the channel identity to its new slot), and a flaky sync mid-reconnect (held traffic files only after a confirming sync). Writing them exposed one gap: _flushHeldChannelFrames documented that a frame for a still-unconfirmed slot re-holds, but the handler only gated on the list-level barrier, so after a partial sync such a message would file by raw index against an unknown identity and persist under the legacy index key. _handleIncomingChannelMessage now re-holds it until a sync confirms the slot. Test plumbing: channelNotifyProbeForTest observes the notify decision (slot, label, PSK, mode) without reaching the NotificationService singleton; wireMessageStoreForTest wires the message store the way the SELF_INFO handshake does. Issue: #1064 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
46255f1ef4 |
fix(#1063): keep per-slot message state from crossing channel identities
Persisted channel history has been PSK-keyed since #194; the remnants that could still cross-wire on a slot reassignment were in-memory and legacy. Epic B of #1061, stacked on #1062: - _completeChannelSync's identity diff now drops the slot's in-memory conversation and reloads it from the store, which reads the NEW identity's own history through the (now fail-closed) resolver. - setChannel's reassignment clear purges only the LEGACY slot-index keys (new clearLegacySlotHistory), so the outgoing occupant's PSK-keyed history survives and comes back if the channel is re-added. True deletes (deleteChannel) keep the full both-key clear. - A WRITE that falls back to the slot-index key now logs an error with the orphaning consequence spelled out; post-#1062 live ingress cannot reach it. - The #193-era comment claiming history is slot-keyed is corrected and the setChannel/resolver interplay documented. Tests: 2 connector tests (in-memory drop on identity change, kept on same identity) + 5 store tests (legacy purge, PSK history survival, no adoption onto the new occupant, true delete). Issue: #1063 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
afbacbc7eb |
fix(#1062): fail-closed channel sync, ingress barrier, slot identity diff
Epic A of #1061, fixing the #1060 class: the client's slot-to-channel map could silently diverge from the device and misroute notifications and conversations. - A slot the device never answers for is UNKNOWN: tracked, surfaced via a persistent channels-screen banner with Retry, and never backfilled from the previous list. Lookups for it fail closed, including notification evaluation, which now suppresses instead of defaulting to noisy. - Ingress barrier: live channel traffic holds until this connection's list is confirmed, then flushes. Pull-based message frames are never dropped (the radio already popped them); lossy raw sniffs stay bounded. Raw-RX no longer matches the offline cache. - Per-slot unread counts survive a sync only when the slot keeps the same PSK; identity changes are listed in a dismissible notice and logged. - A sync with unconfirmed slots does not overwrite the last fully-confirmed offline cache; a sync that fails on a live connection retries itself. New strings English-only per the #1021-era ruling; regenerated l10n + untranslated.json committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week ago |
|
|
77f16755e7 |
feat(#1068): radio per-channel notify contract, caps bit, caps byte 3, tests
Epic A of #544, plus the B1 contract it needed to fail behaviourally rather than as a compile error. Owner directive: app and radio notifications are separate controls, separate notifications, independent settings. So this adds a RADIO-side per-channel mode and touches the app's per-channel setting not at all. - offband_channel_notify.dart: 0x18 GET / 0x19 SET, RadioChannelNotify with the 1-based encoding (0 unset, 1 all, 2 mentions, 3 off), frame builders, and a parser that DROPS an unknown mode byte rather than guessing - offbandCap2ChannelNotify = 0x80, the last bit in caps byte 2 - parseOffbandCaps3 at device-info offset 87, length-gated, never version-gated (#1065). 87 and not 85: 85 is ui_led_enabled and 86 is ui_display_mode, so reading 85 would parse the LED state as a caps mask - connector: per-radio slot-to-mode state cleared in _clearDeviceUiState, request/set shaped on requestChannelTune/setChannelTune so state follows the device's re-read reply and never the request, reply handler, and DeviceUiSurface.channelNotify for refusals 15 tests across three groups. The A2 independence group is the load-bearing one: changing the app mode emits no radio frame, a radio reply saying Off does not mute the phone, and the app setting still works on a radio with no capability at all. That is what stops the two being quietly re-merged later. Proved the checks can fail: flipping the caps-3 offset to 85 fails three tests, including the one asserting the LED and display bytes are not mistaken for byte 3. Reverted. The radio control itself is Epic C; this pane explicitly does not own a channelNotify refusal, recorded at the switch rather than left implicit. Blocked downstream, recorded on #1069: B3's read-on-connect should hang off #1061's confirmed-list barrier, and the slot-to-mode cache belongs in #1061's PSK identity-diff reset set. flutter analyze clean; full suite 1804 passed. |
1 week ago |
|
|
180c01e670 |
docs(#1065): mark the two surviving ver-code reads as a ratified exception
Owner chose Option C on 2026-11-03: keep companionRadioStats >=8 and regionScope >=13, and file the removal as its own issue. That is #1066. Comment-only. Records at all three sites that these two are KEPT, not overlooked, and why they could not be converted: - both gate STOCK companion-protocol features, and a stock reply is exactly 82 bytes, so it carries no capability byte at all; there is no bit to read on a stock radio at any version and the fork cannot make upstream advertise one - the population classifier cannot substitute: stock 1.16 (no region) and 1.17 (region) both land in stockLegacy - dropping them is not behaviour-neutral: stats would read true off a malformed 3-byte frame, region scope would emit frames to a pre-1.17 radio that ignores them, both pinned by existing tests Also records WHY they are safe for now and when they stop being safe: 8 and 13 sit below the conflated 14-22 range, but a future renumbering through 13 switches region scope off silently on hardware that has it, which is the #1065 failure again. The note tells the next reader not to quietly delete them either, because #1066 needs a decision (firmware caps bits making both features fork-only, or probing instead of predicting), not a patch. Full suite: 1739 passed. |
1 week ago |
|
|
747653af90 |
fix(#1065): drop the observer-config version gate too, bit only
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. |
1 week ago |
|
|
92d5cd212b |
fix(#1065): stop three capability gates reading FIRMWARE_VER_CODE
The block list, caplog and packet-hash gates each checked the correct caps bit and then ANDed a fork contract number on top (>=15, >=17, >=22). With the code rolling back to 14 to resolve the 1.18.0 collision, all three returned false on firmware that genuinely has the feature, while the capability bit advertised support the whole time. Silent capability loss. Owner ruling (AM 1401): no dependency rides on FIRMWARE_VER_CODE. The floors were never what protected stock radios, which is why removing them is safe: - the connector parses both caps bytes ONLY when the population classifier allows fork frames, and nulls them otherwise (meshcore_connector.dart :7924-7938), so a stock reply yields null caps and every caps-anchored gate is false regardless of any floor - the classifier keys on the device-info frame SHAPE plus a dual-form version string, never on the ver code (device_population.dart:107-129), so a fork device at 14 still classifies as offband and works - a caps bit is self-dating anyway: firmware predating a feature cannot set that feature's bit, and caps byte 2 only exists from the version that added offset 84 Drops the now-unused parameter from all three signatures rather than ignoring it, and rewrites the POPULATION INVARIANT note to say no caps-anchored gate may read the ver code. Left alone deliberately: supportsCompanionRadioStats >=8 and firmwareSupportsRegionScope >=13 have NO caps bit to fall back on and are pre-split codes meaning the same thing upstream and in the fork; observer config >=14 mirrors firmware's OFFBAND_CONFIG_MIN_VER and is the one floor doing real work. Removing those would be a behaviour change, not a fix. Tests: the three gates asserted capable at ver 14 (4 assertions failed before this change), the bit still the whole gate, and the classifier proven to carry the stock protection on its own. A structural signature assertion was tried and removed: an optional positional parameter still satisfies the function type, so it passed while a deliberately reintroduced >=22 floor was in place. The behavioural assertions were verified to fail against that floor. Also adds the connector-level stock case at ver 14 to #877's test, which previously only covered a hypothetical stock device at 22. Full suite: 1738 passed. |
1 week ago |
|
|
3e40979e94 |
feat(#999): remote-CLI console for a companion, kept out of chat
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> |
2 weeks ago |
|
|
bd6b578136 |
feat(#890): accept and render inbound remote-CLI commands
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> |
2 weeks ago |
|
|
0f0c34b054 |
feat(#888): role-based CLI txt-type selection for remote CLI
F3/E3.1. When sending a CLI command to a remote peer, the message type is chosen by the contact's ROLE (owner ruling): a companion (chat) gets the upstream-1.18 command type (3); a repeater or room server keeps the universal CLI_DATA type (1) it has always used. Decided by role, not firmware version: a Contact carries no version, the user types the commands (passthrough), and a non-1.18 companion silently drops the command type (surfaced as no reply, #891). Adds txtTypeCliCommand (3) and cliTxtTypeForContactType(), and parameterizes buildSendCliCommandFrame with the txt type (default CLI_DATA, so the existing repeater path stays byte-identical). A later refinement could pin a peer's version via a `ver` probe on connect. Tests: the role mapping and the frame's type byte + command passthrough. Full suite green (1430); Gemini 2.5-pro review: one naming nit taken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
2 weeks ago |
|
|
8017ce4baf |
feat(#887): contact remote-CLI permission toggle (flag 0x10)
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> |
2 weeks ago |
|
|
2984ece46c |
fix(#997): make a connect wait for a disconnect still in progress
Owner test 2026-10-26: Stop, then a BLE connect to another radio 6 s later. The Stop disconnect was still releasing the T1000-E; every connect path only refused connecting and connected, so the new connect started during disconnecting. The disconnect's tail then cleared the new link's device, name and state: the status stuck, and the frame size stayed at 20 bytes with MTU 176, which left the channel composer unusable. - disconnect() tracks the one in progress; a connect (BLE, USB, TCP) waits for it, and a second disconnect joins it. A manual caller joining an automatic disconnect still suppresses the retry. - A superseded attempt logs its own radio's name, not whatever name the newer connect has written by then. Watched failing first: the TCP connect started before "Disconnect complete". Full suite 1720 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5fb3286134 |
fix(#997): bound the BLE auto-reconnect and let the user take over
On 2026-10-25 the client retried an unreachable T1000-E about every 14 s for 7.7 hours, and no other connect could start meanwhile (#995). - connect() reset the attempt counter at the start of every automatic attempt, so the backoff never grew and nothing gave up. A ReconnectPolicy now owns the count: 5 attempts or 3 minutes, and only a user connect, a manual disconnect or a successful link resets it. - A connect the user starts, BLE, USB or TCP, abandons an automatic attempt in flight instead of being refused. A generation check after each await in the BLE connect stops an abandoned attempt before it writes shared state. - Refusals are logged and shown, never silent (the USB screen dropped taps without a trace). - The scanner status bar shows the attempt with a Stop button, and "Couldn't reach" with Retry once the retry gives up. Tests watched failing first: the attempt count (1, expected 2), the give-up, the TCP refusal, the silent BLE refusal, and the three screen cases. Full suite 1719 pass, analyze clean. Epic #996, design on #995. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6678700e78 |
fix(#985): decode channel hop counts from the packet, not the byte length
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> |
3 weeks ago |
|
|
72241b1adf |
fix(#750): keep a custom name from following a switch to another radio
_customNames was only ever added to. Rename contact X on radio A, switch to radio B which also holds X, and B showed A's label, breaking the owner's decision that custom names are per radio. - The hydrate clears the map before loading the connected radio's names. - disconnect() clears it with the contact list, so no label outlives the link it belongs to (Gemini finding). T37 (switch) and T37b (disconnect) both failed with the previous radio's "Chuck" before the fix and pass after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
d9c356a4a4 |
feat(#750): reattach the custom name wherever a Contact is rebuilt
Task 750.3 of the contact-naming plan. The custom name lives in contact_settings_store keyed by pubkey, not in contact_store, so the live Contact objects have to be reattached to it. Two paths drop it otherwise, and both fail silently. Cache hydrate rebuilds every Contact from contact_store, which does not hold the custom name on purpose, so a restart would lose every rename the user made. Radio contact-sync replaces an existing Contact with one built from the device frame, where no custom name exists. Adds a _customNames map as the in-memory mirror, _withCustomName to reattach, and _loadCustomNames to batch the load. Batched for the reason already documented on the cyr2lat loader: a notify per contact rebuilds the tree twice per contact and each rebuild walks the contact list. Applied at five sites: cache hydrate and the four contact-sync rebuilds. Paths that copyWith from an EXISTING contact need nothing, because copyWith preserves customName unless clearCustomName is passed, which is why the advert handler from 750.1 already behaves. setContactCustomName is the write path. Null, empty and whitespace all clear, matching Contact.name and the store, so a user who deletes the field's text cannot end up with a blank name and no way back. Eleven tests. T35 was falsified before being trusted: dropping the hydrate mapping made it fail alone. T16 to T22 guard behaviour 750.1 already delivered and passed on arrival, so they are regression guards rather than watched failures, and the file says so. Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1324 tests passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1a3ab19a12 |
feat(#750): keep the user's label off the wire and out of exports
Task 750.2 of the contact-naming plan: the radio boundary. The radio's contact table is the shared record of what a node calls itself, and firmware has a single char name[32] per contact with nowhere to put an override, so a private label must never reach it. This was the hole the type system cannot see. After 750.1 renamed the field, every frame-builder call site still read .name and compiled, which meant they were silently sending the custom name. T11 demonstrated it live before the fix: the custom name was present in the emitted frame bytes. Four connector sites and the stock config export now send receivedName. The export matters for a second reason: a stock config file is an interchange format other operators import, where a name reads as the node's own, so exporting one user's label would hand it to everyone who loads the file. T14 is a source-reading guard, because nothing else can hold this. It fails if any watched file passes a Contact display name into a name field, and separately if the frame-builder call-site count changes. Its allowlist is keyed by path, not by line content, so an exception in one file cannot excuse the same line in another. Correction to the plan's own count: analyze caught that stock_config_import_service reads a StockContact parsed from an imported file, not our Contact, so that site was never wrong. The real number of Contact-typed write sites is eight, not the nine recorded on #750. The reason is in the code and in the guard's allowlist. Eight tests. T11, T33 and T14 were each falsified before being trusted: reverting the export line alone failed both T33 assertions and the source guard independently. Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1313 tests passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
3a0ad8f273 |
feat(#750): separate what a node calls itself from what the user calls it
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> |
3 weeks ago |
|
|
b50d4a73f9 |
fix(#850): live adverts feed discovery only, not the contact list
A long-lived session drifted hundreds of contacts above radio truth (#838): the client promoted every overheard advert into the persisted contact list using only the radio's auto-add type policy, applying neither the radio's hop limit nor its capacity, and ignoring its eviction/full pushes. The firmware fires NEW_ADVERT (0x8A) and the raw RX firehose (0x88) even for adverts it rejects, so those became phantom contacts the next manual sync had to prune. Option A, sync is truth: the persisted contact list is populated only by the contact sync stream. Both live-advert paths now feed discovery only; a new advert whose type the radio would auto-add nudges a debounced incremental reconcile so a contact the radio actually stored still surfaces without a manual sync, while one it rejected simply never returns in the stream. The existing-contact path/GPS refresh and the reconnect full-sync prune are unchanged. The reconcile debounce (and the sibling channels-changed debounce) is cancelled on both disconnect paths so a timer scheduled against one radio can't fire against the next (pre-PR Gemini review finding). Epic #842. Build #850. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
820258471f |
fix(#970): act on the adversarial review, cite the merged firmware
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> |
3 weeks ago |
|
|
f651b3d79d |
feat(#970): say when RTTTL defaults are out of order, and fix them in one tap
The badge's player reads d=, o=, b= positionally, in that order, so RTTTL from elsewhere that lists them otherwise (owner's Take On Me: o=5,d=8,b=160,b=160) is refused as a stray colon, and firmware's 21-column words do not say why. When the header is the only fault, the editor now says the badge reads d=, o=, b= in that order, each once, and offers Fix order. The fix refuses to pick between two different values for one key, and must itself pass rtttlProblem, so it never hides a second problem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1827308339 |
feat(#986, #970, #971, #987): badge tones and screen settings parity over 0xC5
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> |
3 weeks ago |
|
|
317ce17437 |
feat(#981): channel tune codec over 0xC5 0x0B/0x0C
Codec and connector for per-channel tune (#971), gated on caps byte 2 0x10 per meshcore-firmware#1789. ChannelTune makes inherit (stored 0) and Silent (tune id 0, stored 1) distinct values with the byte never built by hand, because collapsing them is unrecoverable from the user's side. Per-channel state follows only the device's re-read reply, a refusal is attributed to the channel-tune surface, and a new radio clears the previous one's tunes. No UI yet: the picker waits for an owner-approved mockup, and for the tune catalogue (builtin ids and names), which is not on the wire. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
4209b75284 |
feat(#969): GPS mode and Power-save interval over 0xC5
Codec for 0xC5 sub-codes 0x0D/0x0E, gated on caps byte 2 0x20, per the contract on meshcore-firmware#1789: the 14-byte state reply (resolved mode, raw stored byte incl. 0xFF, ps_ok, activity, 32-bit interval and wait), extended in place in offband_device_ui.dart and the existing 0xC5 dispatcher rather than a second codec. Caps bits 0x10 channel tune and 0x20 GPS mode added beside the others. On a 0x20 radio the Location GPS card is the approved one Off / On / Power save control, one status row naming backed off with its wait and last fix, and a sleep row only in Power save where 0 reads as the firmware default. Power save is offered only where ps_ok says the build can run it. Every value shown is the device's reply. Other radios keep the #977 on/off toggle unchanged. Safety: a mode change resends the radio's own interval, so setGpsMode refuses visibly until state has been read rather than send an interval it does not know. The 0x7F error frame names no sub-code, so a refusal is attributed to the outstanding write and each screen shows only its own. A confirmed mode keeps the legacy gps custom-var mirror, and so the 0xC1 poll, in step, but never invents the var. Tests: codec, connector (gate, bytes, attribution, mirror) and screen. Full suite 1499 pass. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
6574ecfa90 |
epic(#939): close the stale-read window, and a verdict on #190
Epic D of Feature #853. A different defect class from Epic B: these stores ARE correctly per-radio and bled anyway, through timing. **D2, #158: the window was an unexpected disconnect.** A manual `disconnect()` clears `_selfPublicKey`; `_handleDisconnection()` did not. So after a drop, every per-radio store, and the one `channels_screen` builds from `selfPublicKeyHex`, still held the previous radio's prefix. Connect a DIFFERENT radio and any read before its SELF_INFO landed served the old radio's history. That matches #158 being seen on desktop and not Android: it is a race, not a logic error. `_clearStorageScopeKeys()` now runs on an unexpected drop. It clears `_selfPublicKey` as well as the eleven connector-owned stores and StorageService, because `channels_screen` does not share those instances and derives its key from the getter, so clearing only the stores would have left that path reading stale. Verified before relying on it: every one of the thirteen per-radio stores guards on an empty key, in BOTH directions. I checked rather than assumed, because if any had used a bare prefix instead, clearing the key would have created a NEW unscoped global key, which is the exact bug class this Feature exists to remove. The names and deviceId stay, so the UI can still say which radio it is reconnecting to; only the data-scoping identity goes. **D1, #190: its named root cause is addressed, proven not argued.** #190 blamed slot-index keying with no PSK binding. Two mechanisms landed since: PSK-identity keying (#194) and the reuse-clear in `setChannel` (#193). Rather than assert that, there are tests reproducing #190's own scenario, a slot reassigned to a different channel: - keyed by PSK, the newly added channel reads a different key and sees nothing of the old occupant, which is #190's reproduction - the old occupant keeps its own history, because this is isolation by identity rather than deletion - the index fallback still works and still warns, pinning the remaining surface What remains of #190 is the index fallback firing before the channel list has loaded, which is #333 and a timing problem, so it belongs to this Epic rather than to #190. Recommendation for the owner: close #190 as superseded by #193, #194 and this Epic. Not closing it myself; it is his call and D1 was scoped as a verdict with evidence, not a closure. Suite 1,425 pass, analyze clean, format clean. Citadel: meshcore-open-pdx, meshcore-open-cwd Refs #939, #158, #190, #333, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
afbab311e6 |
fix(#946): classify stock 1.18.0 by frame shape, not version suffix
Real stock 1.18.0 reports its version as "v1.18.0-81dbae5" (semver + git-describe hash), which classifyVersionString reads as the Offband dual form. On a stock-shape (82-byte) frame the classifier treated that as an inverse conflict and failed closed to stockLegacy, so the Device CLI console (#883/#884) was hidden on every real stock 1.18.0 device. Found by E2.2 bench verification (#869) against a live device. An 82-byte device-info frame is definitively stock: the Offband fork always appends at least one caps byte (>=83 bytes). So a stock-shape frame is now classified by ver code alone, regardless of the version string. The fork-shape branch is unchanged - it still requires dual-version corroboration before emitting fork frames, the direction where being wrong is dangerous. Regression test uses the real hardware string v1.18.0-81dbae5. Full suite green (1394); Gemini 2.5-pro review found no defects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 weeks ago |
|
|
a475cc21b4 |
task(#937): B2+B3 path history and delivery observations per radio
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> |
3 weeks ago |
|
|
b59bc728fc |
fix(#471): don't offload the client block list onto a blank radio
On connect, the block reconcile unioned the client's block list with the radio's and pushed the difference, so a fresh/erased radio (empty list) had every client block offloaded onto it and came up pre-blocked (#471, owner verdict ERROR). Owner decision: Option A. A radio that reports an EMPTY block list is treated as blank and is NOT auto-offloaded; the client stays the source of truth and blocks reach a radio only by explicit user action. A radio that already holds blocks still unions normally. Client-side only, no wire change. Added a setOffbandCapsForTest seam so the reconcile is testable. Part of radio-profile isolation Feature #853. The per-radio Blocked view + provenance (#507) is the follow-on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 weeks ago |
|
|
a331075a8b |
fix(#754): scope discovered contacts per radio
Discovered contacts (nodes heard but not added) were stored under one global 'discovered_contacts' blob shared by every radio, so one radio's discoveries showed up while another was connected (#754, Feature #853). Scope the store by the connected device-key prefix like contact_store and the others; the connect block sets the key and the existing startup load clears + reloads the list for that radio. An unset key loads nothing and never writes, so it can't read or clobber the legacy shared blob. No migration: the legacy blob is orphaned and each radio's list self-heals as it re-hears adverts. Part of radio-profile isolation Feature #853. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 weeks ago |
|
|
3055aab931
|
epic(#868): companion CLI codec, service, console + entry (F2/E2.1) (#903)
* 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> |
4 weeks ago |
|
|
285249ae7c |
fix(#848): scope channel notify modes per radio, kill index fallback
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> |
4 weeks ago |
|
|
c2ec7c55a9 |
fix(#880): replace em-dashes in source comments (CI guard #464)
Comment-only; the analyze job's em-dash guard scans source files. Part of epic 866 (container not named in the scope per the push gate). Agent: GentleGlacier (session f0c92a36) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
4 weeks ago |
|
|
3c4ac50bf6 |
feat(#878): version-string population parser
classifyVersionString: stock = bare semver (optional v); Offband dual = semver-dash-remainder (ob-mc, X-X release images, git-describe bench tails); anything else is unknown and corroborates nothing. Stock pre-release suffixes are a documented fail-closed limitation. Agent: GentleGlacier (session f0c92a36) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
4 weeks ago |