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 }
326 Commits (dev)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
f12d5064c5 |
fix(#1128): a key change asks keep or delete; no history stays hidden
Owner ruling: a channel's message history either stays or goes, never kept hidden for a later re-add. - Edit Channel asks Keep or Delete when the key changes (hashtag rename, new private key). Cancel writes nothing. Same-key renames ask nothing. - The answer is applied once the radio confirms the clear. Keep moves the rows (copy, verify, delete only what was copied, repeat) and the phone notify mode; Delete removes the old key's history. - After a sync that saw every slot, confirmed them all, found none erased and had no write in flight, history no channel on the radio shows is deleted, each key logged. Restore's case (erased slots) and partial syncs delete nothing; a radio with no channels deletes nothing. - A different channel over a slot is settled by that sync, since 1.7.0 firmware may move the occupant rather than wipe it. - Keep history that could not land (add refused, move failed) is spared for the connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
c512c39a6e |
feat(#1129): time the sponsor window by use, not by release
Owner's rules, 2026-11-09, after a research pass on prompt timing (Google's in-app review guidance, Apple's 3-per-365-days cap, and donation prompts in other open-source apps): - first shown on the 3rd launch, and no sooner than 7 days after the first - Not now, or closing it, sleeps 30 days - Sponsor sleeps 90 days - Never Show Again (renamed from "Don't show again") ends it - upgrades no longer bring it back on their own - the text adds that support is always available from About in Settings or at offband.org/donate Test builds (GIT_SHA defined) skip every wait and show it once per commit, even after Never Show Again, so each test build can be checked. Tests: 16, each rule with its own test; six guards removed in turn (3 launches, 7 days, the sleep check, the 90-day Sponsor wait, Never, once per commit) and each failed a test. Full suite 2079 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
65b91cffdf |
feat(#1129): sponsor window on the first launch after an install or upgrade
SponsorPromptGate wraps the first screen and, once it has drawn, shows a Support Offband dialog: Sponsor (opens offband.org/donate), Not now, and Don't show again. It shows once per installed version and build, saved before the dialog opens so a crash can't loop it. Hidden on iOS, as the About screen's Donate link is (#1051). A link that fails to open shows the About screen's persistent "Could not open the link". Both keys are app-wide and classified in scoping_inventory.dart. English strings only; untranslated.json regenerated. Tests: 10 widget tests plus the wiring through the gate. Each of five guards (iOS, Don't show again, same version, the gate's call, the failure message) was removed in turn and a test failed. Full suite 2074 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
96ed46afd1 |
fix(#1118): factory reset deletes the app data on send, no reconnect check
Owner decision 2026-11-07: drop the wait. With the box ticked, the radio's app data is deleted as soon as the reset is sent and the link is closed, the same way Reboot just reboots. The pending record, the key check on the next connect, and its complete / kept / did-not-happen messages are gone. A failed delete still shows a banner that stays until dismissed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
6 days ago |
|
|
6b8356fc87
|
Merge pull request #1119 from OffbandMesh/feat/1054-factory-reset
feat(#1057): factory reset the connected radio |
1 week ago |
|
|
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> |
1 week ago |
|
|
7e5a6de8d0 |
fix(#1117): slot 0 is Public's, on every add path
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> |
1 week ago |
|
|
4f76ac73f9 |
Merge remote-tracking branch 'origin/dev' into feat/1054-factory-reset
# Conflicts: # lib/l10n/app_en.arb # lib/l10n/app_localizations.dart # lib/l10n/app_localizations_bg.dart # lib/l10n/app_localizations_de.dart # lib/l10n/app_localizations_en.dart # lib/l10n/app_localizations_es.dart # lib/l10n/app_localizations_fr.dart # lib/l10n/app_localizations_hu.dart # lib/l10n/app_localizations_it.dart # lib/l10n/app_localizations_ja.dart # lib/l10n/app_localizations_ko.dart # lib/l10n/app_localizations_nl.dart # lib/l10n/app_localizations_pl.dart # lib/l10n/app_localizations_pt.dart # lib/l10n/app_localizations_ru.dart # lib/l10n/app_localizations_sk.dart # lib/l10n/app_localizations_sl.dart # lib/l10n/app_localizations_sv.dart # lib/l10n/app_localizations_uk.dart # lib/l10n/app_localizations_zh.dart # untranslated.json |
1 week 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> |
1 week 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> |
1 week ago |
|
|
9a5cd0caf7 |
fix(#1023): every add-channel path chooses its slot at the write
The channels screen was fixed in #1022, but two more surfaces chose a channel slot on their own and had the same race. Community QR scanner: - A channel share link picked its slot before the confirm dialog and wrote it after, so a sync during the confirmation (setChannel itself starts one) could leave it pointing at an occupied slot. The slot is now chosen after the user confirms, and a refusal keeps the screen open with the reason showing. - Joining a community skipped the public channel silently when the radio was full and still showed the green "joined" toast. A refusal now explains itself, and the green toast shows only when the channel was actually written. - Its private slot finder is deleted. Config import: - planChannelImport takes a required isSyncing. Mid-sync the connector's channel list is empty, so the planner would have written every incoming channel from slot 0 up, over Public. It now plans nothing and reports each channel as skipped with a new channelsSyncing reason. The parameter is required so a caller cannot forget it and get the unsafe behavior by default. Both screens now share allocateChannelSlotOrExplain (lib/helpers/channel_slot_ui.dart), so the "which slot is free" question has a single answer. channels_noFreeSlot is retired; its only caller moved to the shared message. Tests: two planner tests for the syncing case, mutation-checked (with the guard disabled both fail). Full suite 1781 pass, analyze clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week ago |
|
|
0506738925 |
fix(#1022): stop channel adds overwriting occupied slots, including Public
Adding #qcchams and #qcchhv from the phone lost Public, from the client and the radio. Three faults combined, all client-side; the firmware's CMD_SET_CHANNEL is a plain indexed write by design and did what it was told. 1. The slot was chosen when the add dialog opened (_showAddChannelDialog computed nextIndex before showDialog and closed over it), so it was stale by the time Add was pressed. 2. The connector empties its channel list for the duration of a sync and refills it slot by slot, so a dialog opened mid-sync saw every slot as free. 3. _findNextAvailableIndex answered 'no free slot' with return 0, which is where Public normally lives, turning a full radio into a silent overwrite of Public. The decision moves into a pure helper, decideChannelSlot, called at the moment of each write and never earlier. It refuses while isSyncingChannels is true and refuses when full, returning a named reason rather than slot 0. Deliberately isSyncingChannels and not hasLoadedChannels: the latter stays false after a FAILED sync while the cached list is restored into use, so gating on it would disable adding a channel permanently on a flaky link. Add Public no longer hardcodes slot 0, which it wrote with no occupancy check. Public is identified by its PSK, not its index, so it takes a free slot like any other channel, and is refused if it is already present. Owner ruling: block the unsafe write but keep the control live, since a greyed-out button is itself a silent failure. Every refusal explains itself and passes persist: true, because showDismissibleSnackBar defaults to a 4 second toast that SAFELANE 6 rules out for an error. A refused add leaves the dialog open so the user can retry. Mutation-tested rather than trusted: restoring return 0 fails 2 tests, and removing the sync guard fails 4, including the exact reported scenario. Full suite 1779 pass. New strings are English-only with the regenerated untranslated.json, matching the convention of every recent string-adding commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week 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> |
1 week ago |
|
|
90fe0d7c93 |
chore(#1106): merge dev, remove the temporary TestFlight push trigger
Brings feat/1014-ios-app-review up to date with dev for PR #1049. Conflicts: the app name in TESTFLIGHT_GUIDE.md and tools/ios-signing/README.md keeps "Offband MeshCore" (#1050); channels_screen.dart takes dev's channel menu (new radio sound tile) and re-applies the #1075 scroll wrapper. testflight.yml is dispatch-only again, as the push trigger was only for the owner's TestFlight pass (#1047). REVIEW-NOTES encryption line and PLAN finding 14 updated to match what App Store Connect shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 week ago |
|
|
cc00375d28 |
fix(#1103): lock a hashtag channel's PSK in Edit Channel, derive on rename
User report (Jeremy AA4KY, iOS; one codebase so the fix is platform-wide): the Edit Channel dialog was type-blind, offering a free-text PSK field and a random-PSK die for every channel. A hashtag channel's key IS its name, so hand-editing it silently diverges the channel from everyone else on that hashtag. - hashtag: key read-only and copyable (copy icon replaces the die), derived live from the name while renaming, exactly as adding that hashtag would. Save refuses a name that stops being a hashtag or carries a double '#' (a '##name' hashes differently), inline error. - community hashtag: name and key both read-only (community-secret derived), key copyable. - private/public/communityPublic: unchanged. - Save also refuses, with a persistent error, when the slot's live occupant no longer matches the channel the dialog opened on, so a background sync replacing the slot mid-edit (the 1060 class) cannot be overwritten by a stale dialog (review finding). Policy helpers are unit-tested; with both guards behaviorally disabled 2 tests fail. Suite 1903 passed / 1 skipped. English strings only; untranslated.json regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
1 week 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 |
|
|
c88ae55057 |
fix(#1057): drop the FIRMWARE_VER_CODE floor on factory reset
Owner ruling: no FIRMWARE_VER_CODE gating, caps-bit recognition only. The reset no longer refuses below ver code 7. A radio that cannot reset keeps its identity, so the pending record resolves as "not reset" on the next connect and no app data is touched. Removes FactoryResetRefusal.unsupported and its string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |
|
|
21fa162d4a |
Merge remote-tracking branch 'origin/feat/1063-message-state-hardening' into feat/1068-radio-channel-notify
# Conflicts: # lib/connector/meshcore_connector.dart |
2 weeks 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. |
2 weeks 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> |
2 weeks 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> |
2 weeks ago |
|
|
4d20965d9e |
feat(#1057): factory reset the connected radio, delete its app data only once proven
Settings > device gets a red Factory Reset tile next to Reboot. The confirm dialog warns that the radio's identity, contacts, channels and settings are erased, with "Also delete this radio's data stored in the app" ticked by default (owner D1=C, D2=A). Wire: stock CMD_FACTORY_RESET (51) + "reset", since firmware ver code 7. Refused, with a persistent message and nothing sent, when no radio is connected or the version is unknown or below 7. The radio disables its link before formatting, so the client never hears the result. The app data therefore waits for proof: a pending record (device, old key prefix, the checkbox) is saved, the frame sent and the session ended so it does not auto-reconnect into a wiped radio. On the next SELF_INFO from the same device, a new key means the wipe worked and, if ticked, every perRadio surface of the old prefix is deleted across drift, prefs and message rows, plus its notify modes. The same key means it did not happen: nothing is deleted. A different device decides nothing. A failed delete keeps the record and retries next connect. The wipe is driven by scoping_inventory.dart, so a per-radio store added later is covered without editing it. An app-root banner reports the outcome, including the BLE note to remove the old OS pairing before reconnecting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |
|
|
c3da9d7541 |
fix(#1050): the app name is Offband MeshCore everywhere
The rebrand wrote Offband Meshcore, against the owner's capitalization. Launcher label, title, strings, notifications, window titles, web, release titles and docs are corrected. ProductName in Runner.rc deliberately keeps the old spelling: path_provider builds the Windows data folder from it, so changing it would orphan every Windows user's data. Bundle IDs, past release texts and the certificate subject are unchanged. Epic #1014, Feature #200. Agent: ChartreuseBay (session 425e424d) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |
|
|
4b9da631f5 |
fix(#1033): tell the user when a Bluetooth scan cannot start
On Android 11 and below the BLE plugin refuses to scan while system Location is off. The Scan button caught that error and only logged it, so the user saw nothing; one tapped Scan 172 times. A failed scan now shows a persistent message: a specific one when Location is off, a plain generic one for anything else, never the raw exception. The detail still goes to the log, and Scan stays pressable. New owner-approved strings scanner_scanFailedLocationOff and scanner_scanFailedGeneric; other locales fall back to English via untranslated.json. Tests: each message present on its failure, absent on success, still shown after 10 s, and Scan retries after a failure. Each check was made to fail first against a broken build (persist false; log-only; misclassification). Plan by EmeraldBrook on #1033. Agent: BrightDog (session 38fb0e08) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |
|
|
92a9cd75a2 |
feat(#1030): reach Share logs and About from the connect screen
A radio that will not connect is exactly when a user needs the log, and the only way to it ran through device Settings, which needs a connected radio. The scanner now has a settings icon that opens an app-only settings screen: App Settings, Debug (Share logs, the logging switch, both log viewers) and About. Navigation only. How logs are written is untouched: Share logs calls the same LogExport.shareLogs the log screens use, on the same on-disk file. The device Settings screen keeps every category. Its About pane and the app-level Debug rows moved into AboutView and DebugLogTiles, which both screens now share; Device CLI, Serial capture and Mesh topology need the radio and stay in device Settings only. Export failures now persist until dismissed and are logged: "No log available to export" was a 4-second snackbar, and a share or save that threw had no handler at all. New owner-approved string: debugLog_exportFailed. Other locales fall back to English per untranslated.json. Tests pin both sides with the radio disconnected: Share logs hands the on-disk file to the share sheet, About shows the version, no device category or radio row appears, both error messages persist, and device Settings still has everything. Each check was made to fail against a deliberately broken build first. Part of Epic #693. Agent: BrightDog (session 38fb0e08) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
2 weeks ago |
|
|
15754624d5 |
fix(#999): offer the companion CLI console only on a 1.18 radio
The companion "CLI console" entry was gated only on the target being a companion. A pre-1.18 radio, including Offband before the absorb, refuses a type-3 send: companion-v1.17.1 CMD_SEND_TXT_MSG accepts only plain and CLI_DATA and answers anything else with ERR_CODE_UNSUPPORTED_CMD (companion-v1.18.0 adds CLI_COMMAND). The connector only logs error frames, so the command waited out its mesh timeout and the console showed the #891 row, which blames the target for a limit of the user's own radio. - Gate: DeviceCliScreen.offersRemoteConsole requires a companion target AND a connected radio that speaks the 1.18 CLI (allowsCliFrame, the same check that gates the local Device CLI; type-3 send and frame 66 shipped together in 1.18). Disconnected or unclassified offers nothing. The chat menu uses it. - Fail fast: RemoteCliTransport fails the command at once with CliConsoleUnsupported when the radio refuses the send. The window arms only when the text frame is written (the onAttempt hook, after the path update) and closes on the command's SENT, which on 1.18 carries an expected-ack of 0. So an unrelated unknown-command refusal before the write, or a DM's SENT, cannot be pinned on this command. Dispose releases a command still waiting. - Console: a refused send shows "<command>: not sent. Your radio's firmware doesn't support remote CLI." (owner-approved), not the timeout row. Tests: the gate (positive and negative across populations, roles, and disconnected); fail-fast on the refusal, ignoring other codes, errors after the radio accepted the send, a DM's SENT, an error before the write, and dispose; the console row. Reviewed by a one-off Sonnet adversarial pass (Gemini budget down). Its Medium finding, that the window armed at run() before the write, is fixed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
63c345e042 |
feat(#891): explain a remote CLI command that gets no reply
A companion that has not granted us remote CLI queues our command as a plain message and never answers, so from the console's side the silence is as likely a permission answer as a lost packet. The console showed the same bare "no reply in N s" row it uses for the local device, which reads as "the radio is down". The remote console now shows: "<command>: no reply in N s. This radio may not allow remote CLI from you, or is unreachable. Not retried." The local console keeps its plain frame-timeout row. The send itself was already a single attempt (#999), so "Not retried" states what happened. - device_cli_screen: the timeout row picks the remote string when the console targets a contact. A transportOverride test seam lets a test drive a deterministic timeout; the screen owns and disposes it. - l10n: deviceCli_remoteNoReply (owner-approved wording). Tests: the remote timeout renders the full interpolated string; the local timeout renders its exact string and never the remote permission hint. Reviewed by a one-off Sonnet adversarial pass; its test gaps are fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks 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> |
3 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> |
3 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> |
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 |
|
|
d1ed88d460 |
feat(#750): let the user rename any contact, stock parity
Task 750.4 of the contact-naming plan: the UI. Rename sits on the per-contact sheet, above the destructive Block tile, and the sheet shows what the node calls itself whenever a custom name is set. The dialog shows the node's own name at the moment of the decision, and only while it differs from what is typed, so the user can see the name they are overriding without having to remember it and the same string never appears twice. It also says plainly that the rename is local and that adverts keep the node's own name up to date, so nobody has to guess what renaming costs them. Empty and whitespace-only are normalized to a clear in the dialog as well as in the connector and the store. Three layers enforce it independently so none is load-bearing and the dialog never hands a blank downstream to be cleaned up. A 32-character cap keeps tiles readable. Counted in characters, not bytes: a custom name never crosses the wire, so #636's grapheme truncation does not apply and a byte cap would mean 'shorter for CJK' for no reason. Thirteen tests. T29 and T30 came from the pre-PR review and I had them wrong first: they accepted an empty string, which IS the defect the review found, so they would have passed against a broken save path. Tightened to assert null strictly, then falsified: reverting the normalization makes both fail where before they passed. Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1335 tests passed. Co-Authored-By: Claude Opus 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 |
|
|
bf47a26155 |
feat(#978): split Button and buzzer into Node Settings and Message Settings
Button actions stay under Node Settings, shown only on caps byte 2 0x02. Buzzer and tones move to Message Settings as their own card under the phone-notifications card, shown only on 0x01. DeviceUiView now renders one part, requests only that part's state, and shows a refusal only on the screen it belongs to. Boards with a buzzer and no button exist (m5stack_unit_c6l, thinknode_m9, wio-tracker-l1-eink), which is why the homes are separate. Placement and entry gates only: the screen already showed each half from its own bit, and every wire write is unchanged. The Buzzer and tones subtitle says 'When this radio beeps' rather than the mockup's 'and which tone', because no tone picker exists until #971 and #970. Tests: all four button/buzzer combinations, and that each entry opens and requests only its own half. Full suite 1506 pass. 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 |
|
|
2a6d7383fc |
feat(#977): Location is a top-level settings category and the only home for GPS
New LocationView pane directly under Radio Settings: the GPS toggle and the 0xC1 GPS status, then Coordinates and Position reporting interval as rows that open their own editors. The GPS toggle and the Location row leave Node Settings, and the combined Location dialog is gone, so GPS appears in exactly one place. What the radio sees is unchanged: the same gps and gps_interval custom vars, the same 60 to 86399 s validation, the same error messages. One deliberate difference: coordinates and the interval now save independently. The old dialog returned early on an empty or invalid interval and silently skipped saving the coordinates too. Tests: settings_location_test pins placement from both sides and the writes. Two existing settings tests now scroll a category into view before tapping it; the lazily built category list put Debug past a 900 px viewport once Location was added. untranslated.json is a full regeneration and also picks up two block_noRadio keys dev was already missing. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
3 weeks ago |
|
|
5b061924c7 |
feat(#966): mock-ups, a confirmation that states the scope, and the eligibility bug it found
The owner asked why there were no UI mock-ups to review. There was no good answer: this Epic's card was built and installed on both his machines without him ever seeing the design, and the plan had no step requiring otherwise. So the plan now has B0: mock-ups first, owner-approved before any of it is built, as a committed HTML file beside the plan in the format his firmware initiative already uses. epic-b-storage-mockups.html is that file: eleven numbered variants with real Material 3 tokens, a 360-wide pane, example values from his own install, and the behaviour to build as bullets under each. Owner-approved 2026-10-23. The confirmation (B1a, variants 8 to 10). It states how many copies, roughly how much space, how many messages are NOT affected, and that it cannot be undone, with the count on the action button so the scope is the last thing read. Cancel, a tap outside and back all decline. Nothing eligible opens no dialog at all, because a dialog that only says zero is not worth opening. That needed a preview pass: verify every eligible key and total it without deleting, so the numbers are real rather than estimated. Verification now runs twice for a confirmed clear, once to preview and once under the key lock. That is the cost of saying what will go before it goes. approximateBytes is named for what it is: the sum of blob lengths, not what VACUUM returns, because page rounding moves the real figure; the dialog says "about" and the result reports what actually changed. Writing the preview found a real bug, and it was about to bite him. The "skip anything stamped this pass" guard exists so installing an update never deletes a blob on sight, and it was being applied to the button as well. Every install that migrated before #962 has an empty __msgrows_migrated_at, so the first press stamped everything and cleared NOTHING. His install is exactly that: 47 migrated keys, none stamped. He would have pressed the button on a 70.9 MB database with 16.6 MB of reclaimable blobs and been told "nothing to clear". Fixed as eligibleByAge, one static rule both preview and run share, so the dialog cannot promise what the sweep will not do. An explicit press includes newly stamped keys; the automatic sweep still refuses them. Three tests on the rule directly, plus one proving the automatic path refuses while the press clears. Label stays "Clear Stale History Now" rather than "Compact", owner's call and the better argument: the name generalises to clearing stale history for other reasons later, such as #963's retention limit. 14 widget tests and 34 storage tests. Full suite 1,502 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
cc2fb23b2e |
feat(#966): Clear Stale History Now, with refusals reported
Epic B of #962. The manual trigger for Epic A's sweep, so a user does not wait 30 days. It passes olderThan: Duration.zero, which skips the WAIT and not the safety: the per-key verification inside reclaimVerifiedHistoryBlob is unchanged, proved by a test that Duration.zero still refuses a key whose rows cannot be verified. Its own widget rather than another card in app_settings_view.dart, which is already past a thousand lines, and so the tests can drive it without standing up the whole settings screen. Stateful against the repo's usual StatelessWidget-plus-Consumer habit, because the running flag and the last result are local to this card and belong to no service. The sweep is INJECTED, and that is a finding rather than a convenience. The first version of the widget test called the real sweep and hung past ten minutes instead of failing: the sweep uses compute, and an isolate deadlocks pumpAndSettle inside flutter_test's fake-async zone. A widget test should test the widget. So the card takes the sweep and the size read as parameters, the nine widget tests feed outcomes in directly, and the two new tests in stale_history_sweep_test.dart keep the injected semantic honest under plain test() where isolates work. Owner decision 2026-10-22, and the reason this Epic exists: the button REPORTS keys it refused, it does not only log them. "Cleared 1 old copies, 16.0 MB returned" and "Kept 1 that could not be verified" are both shown, including when a refusal is the reason nothing was cleared, which is the most important case to surface rather than the least. The automatic sweep stays log-only. Two Gemini findings, both fixed: - A fast double tap ran two sweeps. The disabled button is not enough on its own, because setState only SCHEDULES a rebuild, so two taps inside one frame both see a live onPressed. _clear now returns early on _running, and a test taps twice in one frame and asserts the sweep ran once. Two concurrent sweeps would have meant two concurrent VACUUMs. - A throw after real deletes was reported as a total failure. The honest fix was in the sweep, not the string: a successful sweep must not become an error because the closing size read threw, so that read now falls back to bytesBefore, which understates the reclaimed figure rather than inventing one. The failure string no longer claims nothing happened either. Strings are English-only, as everywhere else here: gen-l10n falls back to the template locale, and untranslated.json records the 9 keys across 17 locales, which is what that tracked file is for. 29 tests in the two files, full suite 1,483 pass, 1 skipped. analyze clean, format clean. Task: meshcore-open-gan Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
a21ff98c6c |
epic(#938): Blocked pane says "no radio" instead of "no blocked users"
Epic C of Feature #853, carrying #507. Much smaller than the plan assumed, because `BlockedView` already existed and was already wired into Settings. What it did not have was the distinction #507 asked for, or any tests. **The gap.** With no radio connected the pane rendered "No blocked users", which is indistinguishable from a connected radio with an empty list. Since #471 made blocks per radio, that wording invited exactly the wrong conclusion: that the blocks had been lost. Now a disconnected pane says "No radio connected" and explains why ("Blocks belong to the radio that holds them"), because the reason is what stops it reading as data loss. English-only string additions per the repo convention; `untranslated.json` tracks the other 17 locales. **Tests: 5, and the first one has teeth.** I proved it by disabling the new branch and watching it fail: Expected: exactly one matching candidate Actual: Found 0 widgets with text "No radio connected" Then reverted. A test nobody has watched fail is not evidence, and this session has already produced three harnesses that reported success while measuring nothing. Covered, all through the widget rather than the service: - no radio says so, and gives the reason - a connected radio with nothing blocked says THAT - a block made on radio A is absent on radio B - switching back restores A's list, because isolation is not deletion - an unblock survives a switch and back, which is #507's clear-sticks case and the re-clear that #471 was about #507 also lists "migration drops the global keys". That already exists: `BlockService.loadForDevice` calls `_store.dropLegacyGlobal()` on every device load. Noted rather than re-implemented. Suite 1,419 pass, analyze clean, format clean. Still outstanding for this Epic: the T1000e hardware check #507 asks for, which is an owner step and the only part I cannot do. Citadel: meshcore-open-uu9, meshcore-open-9ft Refs #938, #507, #471, #853 Co-Authored-By: Claude Opus 5 <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 |
|
|
5d56b0e8ba |
fix(#830): path-aware repeater picker in Set Region Scope (D1 Option 1)
The picker lists only repeaters with a stored path (the only ones that can answer), with a header note explaining that. Search surfaces pathless matches too, but greyed out and non-selectable with a 'No stored path' label, so the user sees the repeater exists and why it is blocked. Default selection skips pathless repeaters; Discover is disabled for them. Widget test covers hidden-by-default + greyed-on-search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
1 month ago |
|
|
f26fdabe95 |
fix(#830): alpha-sort + searchable repeater picker in Set Region Scope
D1 hardware finding: the repeater picker was an unsorted, unsearchable dropdown, unusable with many repeater contacts. Replaced with a case-insensitive alpha-sorted, searchable list; discovered regions are alpha-sorted too. Widget test asserts sort order and search filtering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
1 month ago |
|
|
24ca2abe3a |
feat(#830): channel Set Region Scope action + picker (Epic #815 C2)
A firmware-gated channel overflow action opens a picker that discovers regions from a chosen repeater (reusing RegionDiscoveryService), lets the user select a region or None, and persists it via connector.setChannelRegionScope (C1 store). The dialog is decoupled (data + callbacks + injected service) for widget testing. l10n added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
1 month ago |
|
|
d3b29d3ad5 |
feat(#826): Discover Regions UI + l10n (Epic #814 B2)
A firmware-gated repeater-hub tile opens a Discover Regions screen that runs RegionDiscoveryService against the selected repeater and renders each state distinctly (loading / region list / no-regions / timeout / error, with retry). Shows region name + source repeater; the wire reply has no per-region SNR, so none is shown (the B2 spec's SNR is not available). Widget tests cover all four states via an injected fake discoverer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
1 month ago |
|
|
d740124b74 |
feat(#764): short-sync banner gives the recovered count and contacts confirmed gone
The banner and the Keep / Use radio dialog now appear only after the forced resync, so their numbers come from the whole recovery: how many contacts the app has after checking again, and, when the radio answered "not found" for any saved contact, how many are no longer on the radio. That sentence is absent when there are none. English strings only; other locales fall back, regenerated untranslated.json lists them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
8939541f79
|
Merge pull request #746 from OffbandMesh/feat/733-remote-presets
epic(#733): apply radio presets to repeaters, room servers and sensors |
1 month ago |