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 }
217 Commits (76216f08dfc4cb491c7bebfd3eeafb3a4a56f25b)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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. |
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 |
|
|
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 |
|
|
d476734aac |
feat(#889): route CLI to companions by target role
F3/E3.1 target split. The command service now picks the message type by the target contact's role: a companion (chat) gets TXT_TYPE_CLI_COMMAND (type 3), a repeater/room server keeps the universal CLI_DATA (type 1) it has always used. One line in the send path; the reply handling, correlation and timeout model (which already carries the 600 ms companion reply hold) are unchanged and serve both. Companion replies come back as CLI_DATA (verified: MyMesh.cpp companion), so the existing contact-agnostic reply listener routes them to handleResponse unchanged; a UI entry point to open the CLI console for a companion is the remaining downstream piece. Tests: a real send proves companion -> type 3 and repeater -> type 1, driven deterministically off the send frame. Full suite green (1432); Gemini review: test race fixed, the reply-path concern refuted against the source-verified CLI_DATA reply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
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 |
|
|
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 |
|
|
3168c413c9 |
feat(#966): a durable audit of what the sweep deleted
The owner asked whether the delete and compact are logged or silent. Checked rather than assumed, and the answer was bad on two counts: - A delete logged only a COUNT. "Cleared 40" names nothing, so there was no record of WHICH keys were reclaimed. Refusals were already logged properly, with the key and up to three missing records; deletions were not. - All of it goes through appLogger, which is gated on appDebugLogEnabled. That is off by default and off on his install, so on a normal install an irreversible delete left no trail at all. Both fixed. The summary now names the cleared and kept keys, and the sweep also writes a durable entry into the database itself, under __msgrows_reclaim_audit: timestamp, the cleared keys in full, the refused keys, and the byte counts either side. In the database because that survives restarts and the logging toggle, and can be read back without the app running, which is also how it gets verified on hardware. Key lists are stored in full. Truncating them would recreate the "Cleared 40" problem the entry exists to solve. Capped at the last 5 sweeps instead, which is generous given a key is cleared once and then has no blob to consider again, so this is close to a once-per-install operation. A failure to write the audit is logged and tolerated rather than failing the sweep: the deletes already happened and were each verified, so losing the record is worse than nothing but far better than reporting a correct sweep as broken. An unreadable audit likewise starts a fresh list and is logged, never silently replaced. scoping_inventory gained the key, globalOnPurpose: one sweep spans every radio and the entries name store keys that already carry the radio. Six tests: a sweep records which keys it deleted; the entry survives a fresh store object over the same database, so it is really on disk; refusals are recorded too; a sweep that did nothing writes no entry; the cap drops the oldest and keeps the newest; and an unreadable audit does not stop a sweep. Lands on Epic B's branch rather than Epic A's, where the sweep lives, to avoid restacking B over a changed A for a change that reaches dev through both either way. Origin is #965's engine. Full suite 1,489 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 |
|
|
84c2dbd9da |
fix(#965): act on the Gemini gate findings
Three findings, all addressed. One was a real data-loss hole. 1. CRITICAL, fixed. missingFromRows trusted the converter's own output as the measure of the converter. A callback that silently dropped a record, rather than throwing, would have had every record it DID produce found present, returned "nothing missing", and the blob holding the only copy of the dropped one would have been deleted. The migration gate has always guarded this with an independent countJsonArrayElements in a separate compute; the verify path I added did not, which left the two halves of the same rule unequal. It now does the same cross-check and refuses as unverifiable on a mismatch. New test: a converter that drops its last row is refused with missing == null, not missing == []. 2. Measured, not redesigned. VACUUM takes an exclusive lock, and the review called it a UI freeze. It is not: drift_flutter runs SQLite in a spawned isolate (_isolateEntrypoint -> DriftIsolate.inCurrent, verified in the package), so database access queues behind it while the UI thread stays free. That is a stall on history-backed reads, not an ANR, which is the distinction that matters for an app whose storage work began as an ANR investigation. It is also close to once per install, since a cleared key has no blob to consider again. The duration is now logged, because 0.6 s on the owner's 70.9 MiB database is a number worth watching in the field rather than assuming. 3. Fixed, and the review was right about why. The non-history-key test asserted only that the isHistoryKey helper returns false, which would still pass if run() stopped calling it: the loop visits keys that HAVE rows, and contacts and friends normally have none, so they were never reachable anyway. Those keys now get a planted row and a 99-day-old timestamp, so the prefix guard inside run() is the only thing standing between them and a delete. Also carried over from the previous round: the check-then-delete window is closed by BlobStore.reclaimVerifiedHistoryBlob, which holds the per-key lock across both the verify and the delete and so also serialises the sweep against a migration or a blob merge on the same key. Two tests cover it, including that a second call on a blobless key reports neither a delete nor a problem, which is what makes a repeat sweep silent. On the owner's database 7 of 47 aged keys are in that state, having migrated through the empty-blob path. 18 tests in the file, full suite 1,472 pass, 1 skipped. Re-verified against a copy of the owner's real database after the changes: blob keys: 477, migrated history keys aged: 47 cleared 40, refused 0, 17362944 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29950 -> 29950 Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
1b38c674e4 |
feat(#965): per-key migration timestamps and the stale-blob sweep
Epic A of #962. The engine only; the manual action is #966. A1 wrote the diagnosis as failing tests first, and they were red for the right reason: no sweep existed and six BlobStore methods did not. Also lands a control test asserting that DELETE alone does not shrink the database, which passes today and must keep passing, since it is the entire reason a VACUUM step exists. A2, the timestamps. A second blob, __msgrows_migrated_at, keyed by store key. Deliberately NOT a reshape of __msgrows_migrated: that marker is read on every save and every load through a cache, so changing its shape would put a migration of its own on the hottest storage path. Owner decision 2026-10-22. An install that migrated before this ships has no timestamp, so backfillMigratedAt stamps it on FIRST SIGHT and the sweep skips anything it stamped this pass: updating the app can never delete a blob on the spot, and every key gets the full 30 days measured from when we first knew about it. A3, the sweep. Eligibility is age plus a fresh verification, never the marker alone, which is the difference between "we recorded that this migrated" and "we just confirmed it". missingFromRows returns null for "could not tell" and an empty list for "verified nothing missing", and those are deliberately different values: collapsing them is how the only copy of an unreadable history gets deleted. A4, one VACUUM, only when something was deleted. A failure there is logged and tolerated: the deletes already happened and were each verified, so it costs space and not data, and the next sweep retries it. The comparison rule now lives in ONE place, _absentFrom, used by both the migration gate and the sweep. The gate still decodes its own blob, because a second compute over multiple megabytes on the migration path would cost more than the duplication saves, so the rule is shared even though the decode is not. Wired into main.dart's existing post-frame callback, beside the #906 exit-info collector, for the same reason: storage hygiene must never be on the path to a usable screen. The catchError sits at that fire-and-forget call site rather than inside run(), so #966's button still gets a failure it can report. scoping_inventory gained the new key, because the #853 gate caught it exactly as designed. Its verdict is globalOnPurpose for the same reason as the marker: it is keyed BY store keys that already carry the radio. 15 tests, the plan's whole acceptance bar: the 29-day and 30-day boundaries; one missing row refuses AND the same key clears once its rows are complete, which is what proves the gate discriminates rather than never deleting; an undecodable blob refuses; an unstamped key is stamped not deleted; an unmigrated key is untouched; contacts, discovered_contacts and path_history_ are asserted non-candidates BY NAME; a DM key sweeps through the DM converter; and every kept blob is byte-identical across the sweep and VACUUM with the row count unchanged. A5, verification against a copy of the owner's real database through the production sweep: blob keys: 470, migrated history keys aged: 47 cleared 40, refused 0, 17367040 bytes returned (16.56 MiB) kept keys missing: 0, byte-changed: 0 integrity_check: ok, rows 29949 -> 29949 47 aged and 40 cleared because the other 7 migrated through the empty-blob path and never had a blob to reclaim. Full suite 1,469 pass, 1 skipped, up from 1,454. analyze clean, format clean. Task: meshcore-open-5vt Agent Mail: TealGlen Co-Authored-By: Claude Opus 5 <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 |
|
|
a7882b62e0 |
task(#937): B1 delete the dead pending_messages API
#855 was filed as "pending messages are stored globally, not per radio". It is not a scoping bug. `loadPendingMessages`, `savePendingMessages` and `clearPendingMessages` had no callers anywhere in lib/ or test/, and the key was absent from the owner's live prefs. The `_pendingMessages` field in message_retry_service.dart is an unrelated in-memory list that happens to share the name. So the surface is DELETED, not scoped: three methods and the key constant. Scoping it per radio would have been three careful changes and a migration decision, for an API nothing calls. This is the finding that justified consolidating #854, #855 and #856 into one Epic instead of landing three PRs. Nobody reading #855 on its own would have gone looking for callers, because the issue already told them what the fix was. Removed with it: - the inventory row, replaced by a comment saying the surface is GONE rather than reclassified, so the history is readable - A2's round-trip test, whose job was to prove the thing being deleted actually worked. It served that and then became a test of nothing - 855 from the known-holes expectation, which now reads {854, 856}; that set shrinking to empty is what closing Epic B looks like What still guards it: the inventory's "declared but vanished from source" check fires if `pending_messages` ever comes back undeclared. Citadel: meshcore-open-li1 Refs #937, #855, #853 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
3 weeks ago |
|
|
8cea58ec1d |
fix(#931): two defects from the second pre-PR review
1. existingFiles() walked hardcoded names with per-name exists(), so the periodic flush could rotate mid-walk and return a name it had just confirmed and rotation then renamed. Handing the share sheet a path that no longer resolves fails the WHOLE export, which is the exact failure the rotated-log fix existed to prevent. Now one directory listing, filtered and ordered by name. flushAndGetAllFiles() degrades to the active file if listing throws, because the logger cannot report its own listing failure anywhere, and one log beats none. shareLogs() also re-checks existence at the last moment, since a trace can be pruned between collection and share. 2. forceDartIsolateBlock used DateTime.now() for its deadline. The wall clock can move backwards, which would push the deadline away and leave the app frozen past the window or indefinitely, needing a force-kill on a test build. Now a Stopwatch, which is monotonic, matching what the Kotlin block already did with nanoTime. analyze clean, 1307 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
4 weeks ago |
|
|
4ab969b7a1 |
feat(#931): second ANR trigger that blocks the Dart isolate, not Kotlin
The existing trigger hangs the Android main thread, so its trace is a Java stack. That proved the capture works and that our Kotlin frames resolve via the R8 mapping, but it cannot say whether our own DART frames survive into a trace, which is the question #906 was filed to answer. This one blocks the Dart UI isolate the way the real ANRs do rather than with an artificial spin: it repeatedly JSON-encodes a large nested structure shaped like the stores #334 is about. The Play clusters show the real ANRs inside the JSON stringifier and under allocation pressure, so a trace from this block should match their shape, and whether any of our Dart frames appear above those is then readable. It spins deliberately. Any await in the loop would yield to the event loop, the isolate would keep answering input, and no ANR would fire. If the process survives the full duration it logs why, so the tester is not left guessing. Gating is unchanged and still doubled: the bool.fromEnvironment constant keeps both rows out of any shipped build, and the owner-only Experimental section hides them again in a test build. Both rows are relabelled native side and Dart side so they are distinguishable. Tests: both triggers asserted to be immediate no-ops off Android, which matters because a missing platform guard would hang a desktop build instead of failing visibly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
4 weeks ago |
|
|
d2e9358f4d |
fix(#910): four defects from the pre-PR review
1. The export shipped only the active log, and FileLogWriter._rotated was private, so offband.1.log and .2.log were unreachable by anything. An exit-record pointer line written at startup rotates out after a couple of MiB of ordinary logging, handing over a trace file with its metadata gone. FileLogWriter now exposes the files that exist, keeping the rotation naming where it belongs, and the mobile export attaches all of them. 2. dispose() called ioExecutor.shutdownNow() while a read could be holding a MethodChannel.Result. Interrupting it left the Result neither succeeded nor errored, so the Dart future never completed. Now shutdown(), with a disposed flag and a RejectedExecutionException path that always replies. 3. The trace read was skipped for REASON_CRASH_NATIVE on the assumption that its stream is always a protobuf. AOSP says a trace from an earlier ANR stays attached even when the process later died for another reason, so gating on the reason loses real text traces. The read is now attempted on every record and a printable-bytes check decides whether the content is usable. 4. The test-only main-thread block used a single Thread.sleep, which an interrupt could cut short: no ANR, no record, and a wasted trip to the hardware. It now sleeps to a deadline and ignores interrupts, which guarantees the duration without a busy-wait's CPU burn. Review also flagged that FileLogWriter can exceed maxBytes for a single oversized entry. That is pre-existing writer semantics, already pinned by a test in this branch, and deliberately not changed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
4 weeks ago |
|
|
94cc24a95f |
fix(#910): hide the ANR test row off Android
Exit records are Android-only, so on a desktop build the dart-define alone would leave a button that does nothing. The row now needs the flag and a supported platform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
4 weeks ago |
|
|
24a1a741d7 |
feat(#910): Dart exit-record service, scheduled after the first frame
Reads the records over the channel, logs one line per record whatever its reason (the owner's scope ruling on #908), and writes any ANR trace to a file beside offband.log. Dedup is a single persisted integer, the highest exit timestamp already logged. Records come back newest first, so a pass takes everything strictly newer than the watermark and then advances it to the highest seen; two records sharing a millisecond are both taken in that same pass, so neither is lost. A missing watermark is treated as a capped backfill and labelled as such, so a months-old ANR is not misread as fresh. Scheduled in a post-frame callback and deliberately not awaited, so startup time is unchanged. Every failure path writes a line and none is silent; a failed query leaves the watermark untouched so the next launch retries. An explicit flush follows the writes because FileLogService batches on a 2 second timer, and without it a startup line can be lost, leaving a trace file with nothing pointing at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
4 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 |
|
|
2ff7c71aa4 |
fix(#825): guard RegionDiscoveryService notify after dispose
Pre-PR Gemini review (Epic #814) flagged a use-after-dispose: if the screen pops while a discovery is in flight, the completing future would call notifyListeners on the disposed service and throw. Added a _disposed flag + _safeNotify guard + dispose override, with a regression test that disposes mid-discovery. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 weeks ago |
|
|
a096d6eae9 |
feat(#825): region discovery lifecycle service (Epic #814 B1)
RegionDiscoveryService (ChangeNotifier) drives one discovery run via an injected discoverer function and exposes a distinct state for each outcome: idle/loading/success/empty/timeout/error. Regions deduped by Region value-equality. Concurrent starts ignored. Injected seam keeps it unit-testable without a live connector. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 weeks ago |
|
|
49c77f09db |
fix(#765): clearer recovery log past the sync cap, and name CMD_GET_CONTACT_BY_KEY
Found in the owner's hardware test: a manual refresh after the 3 automatic syncs logged "full sync 4 of 3", and the traffic log showed the by-key request as CODE_30. Log text only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
e054d894f5
|
Merge pull request #744 from OffbandMesh/feat/726-preset-catalog
epic(#726): in-app radio preset catalog and picker (Companion/Observer) |
1 month ago |
|
|
e990cc1f7c |
fix(#711): drain serial on each callback; never drop bytes silently
Two app-side halves of the #702 fix. The native USB service read one 8 KB chunk per flserial callback, which left the rest of a burst waiting in the native ring. It now drains until the ring is empty, bounded at 32 reads (the ring's 256 KB) so a stuck read cannot spin. The frame decoder skipped bytes it could not frame (noise, an oversized length) without a word, so when bytes were lost upstream, whole frames vanished with no trace. It now counts them and keeps the first 16; the native USB, web USB and TCP services log each batch as an error with the count and a hex sample (SAFELANE 6). The web service shares the decoder, so it gets the same logging, matching the plan's "all transports" intent. Tests cover no report on clean input, noise before a frame, an oversized header, take-once, the capped sample and reset; with the count disabled, 4 fail. Epic #702. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
3c7c4d9d65 |
feat(#728): load radio presets from bundled copies and refresh them from config-profiles
RadioPresetService starts from the last good copy of each preset file, or the copy bundled in assets/radio_presets/ (config-profiles 857d4e9), so the picker works offline. refresh() fetches both files from config-profiles; each file that parses becomes the new saved copy, and one that fails or comes back empty never replaces a good one. Failures are logged and kept in refreshError for the picker to show. refreshIfStale() refreshes at most every 12 hours. The merged list puts the overlay over upstream on a matching title, sorted by region. An overlay edit in config-profiles therefore reaches users on their next refresh with no app release (test: "an overlay edit reaches the app with no new build"). Closes the gap recorded in #648. Epic: #726 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
1 month ago |
|
|
9a12794487 |
fix(#589): late replies go to the app log, not the user's screen
#528 surfaced unmatched and late replies in the UI because they were previously destroyed in silence. Once #529 widened the window, late arrivals stopped being the exception and the on-screen output became noise during ordinary use. Owner's call: log it, keep it off the screen. Removed: the CLI screen's late-response history entry and its renderer branch, the settings screen's snackbar, and the four l10n strings that only those two used. Also removed the settings screen's silent late-apply. A field changing value seconds after the fact with no explanation is the same problem as the snackbar, only quieter, and applying while saying nothing would be worse than either showing or not showing. One line to re-enable if that turns out to be wanted. Kept: the service still detects every unmatched reply and writes it to the app log via appLogger, now including the payload as well as the repeater, the originating command when known, and how late it arrived. That is the diagnostics channel and is what keeps this from being a silent drop. onUnmatchedResponse and UnmatchedRepeaterResponse stay on the service. They are the seam the no-silent-drop guarantee is tested through; the #528 and #532 tests exercise them directly and pass unchanged, which is the evidence the service contract did not move. 807 tests pass, analyze clean, format clean. |
2 months ago |
|
|
9741c3399d |
fix(#575): apply pre-PR review, stop claiming unconfirmed import success
Adversarial review raised three findings. Two survived verification. Rejected: a claimed RangeError when abbreviating a short public key. The code already guards with a length check before either substring, so the crash it describes cannot occur. Rejected as described, fixed as found: contact position (0, 0) was said to be dropped on import. It is not. The frame builder writes the position block whenever lastModified is present, which it always is here, so a suppressed position still writes 0/0 and the bytes are identical either way. The hasPosition conditional was therefore doing nothing except misleading a reader, which is exactly what happened. Removed, and the behavior is pinned with a test. Accepted: the service added sections to 'applied' after sendFrame returned, which only means the frame left our side. A lost or refused frame was indistinguishable from success, so the user could be told an import worked when nothing changed on the device. A correct fix needs an awaited per-command acknowledgement in the connector, keyed on command code so concurrent commands cannot steal each other's OK. That is real connector work and is filed as #584. What is fixed here is the overclaim: 'applied' and the two counts are documented as sent rather than confirmed, and the result string now reads 'Sent N contacts and M channels to the device'. The code no longer states something it cannot know. |
2 months ago |
|
|
ee1ba064ac |
feat(#575): stock config import service
Applies a stock config file to the device with stock's own merge semantics, quoted from its Import Config screen: - contacts are upserted on public key, and nothing is ever deleted - channels are additive only, so a rotated PSK for a channel the user already has does not apply - importing an identity overwrites the node outright We match that behavior but not its silence. Every skipped channel comes back named, with a reason, so the UI can show it; a channel that quietly did not import is the silent no-op SAFELANE section 6 forbids. The channel merge rule is extracted as planChannelImport, a pure function, so the surprising part is testable without a radio. It treats a channel as already present when either its PSK or its name matches, which is the non-destructive reading of a rule stock states without defining. Pinning down what stock actually does is a T1 question, recorded in the doc comment. New channels take the lowest free slot, since the file carries no index. other_settings is reported as not fully writable rather than claimed as applied: buildSetOtherParamsFrame deliberately pins the auto-add-contacts byte, so manual_add_contacts cannot be written without changing app-wide behavior. Advert location policy is applied. 10 tests over the planner and the result type. |
2 months ago |
|
|
16c2b4a770 |
feat(#573): stock config export service
Gathers live device state into a StockConfig, section by section, matching stock's Export Config screen where each section is independently selectable and the file simply omits what was not ticked. A requested section that cannot be gathered is reported in the result rather than dropped, with the reason kept specific: unsupported (firmware built without identity export) is distinct from noReply and rejected, so the export screen can say the radio cannot do it instead of offering a pointless retry. Two unit traps handled explicitly: - currentFreqHz is misnamed; the value is kHz, which is also what stock's frequency field carries, so it passes through unconverted. currentBwHz really is Hz. The mismatch exists in both our state and the file. - other_settings.manual_add_contacts must be the raw device byte. The connector's _manualAddContacts is an inverted derived view of bit 0 (firmware treats a clear bit as auto-add enabled), so exporting it would have written the wrong value. The raw byte is now retained and exposed, with the inversion documented at the parse site. No behavior change. Path mapping keeps our hash count and per-hop width (#309) intact: stock encodes one hash per comma-separated element, so the width survives as element length. A flood route, an over-long declared hop count, or a width stock cannot express all yield no path rather than a guessed one. 13 tests over the pure mapping functions. |
2 months ago |
|
|
d27048f94a |
fix(#678): distinguish CoreScope not-found from unreachable (#571)
CoreScopeService.fetchCounts returns a three-state CoreScopeResult { found | notFound | unreachable } instead of a nullable count. Reachable-but-empty -> notFound ('Not seen by CoreScope yet'); network/HTTP/parse error -> unreachable. Channel-chat tap-refresh banner switches on status; adaptive poll reads .counts.
Epic #677. Closes #571.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
2 months ago |
|
|
edffd10555 |
fix(#581): adopt the radio's RESP_CODE_SENT ACK hash for send correlation
The radio is authoritative for the expected-ACK hash it reports, but the client looked that hash up in a map keyed by a hash it recomputed locally. When the two disagreed the lookup missed silently (debugPrint only), the 8000ms watchdog from #395 marked the message failed, and the genuine ACK later matched nothing because every downstream map is populated only on the match path. Delivery worked the whole time. Captured on a Wadamesh HV4 TFT in #449: five DMs, all shown as errors, two with a confirmed ACK whose hash equalled the radio's RESP_CODE_SENT value exactly. Wadamesh builds the payload differently, so the client cannot predict its digest, and Offband is expected to work against stock MeshCore, Wadamesh and other forks. RESP_CODE_SENT is the reply to our own CMD_SEND_TXT_MSG, so adopt the radio's value when exactly one send awaits confirmation. Zero or several candidates keep the previous behaviour rather than guessing. Channel sends draw the same frame, so adoption is refused while one is outstanding. The lookup miss is now a warn on debugLogService instead of a bare debugPrint (SAFELANE section 6). A send-order correlation queue was developed alongside this and has been split out: its ordering premise needs a transport send mutex that does not exist yet, and three review rounds each found a fresh defect in it. This commit deliberately carries only the adoption fallback, which is what the owner's hardware test validated. Refs #449 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
2 months ago |
|
|
e145ef7481 |
fix(#528): apply the three accepted Gemini review findings
1. An unprefixed reply no longer resolves an arbitrary pending command. #532 constrained prefixed replies only; the unprefixed fallback still used firstWhere, which with two or more in flight is map-iteration order, so a caller could receive another command's output. The fallback cannot just be deleted: firmware only echoes the prefix when the command exceeds four characters including it (simple_repeater/MyMesh.cpp, strlen(command) > 4 && command[2] == '|'), so a very short command legitimately answers without one. It now resolves only when exactly one command is pending, and surfaces the ambiguity otherwise. 2. A late reply no longer overwrites unsaved edits. The settings screen applied a late `get` payload unconditionally, so a reply arriving after its timeout could revert a field the user had typed into while waiting. It now applies only when nothing is dirty, and says which happened. 3. _expiredCommands is pruned on timeout as well as on receive. It was pruned only in handleResponse, so a run of commands that all time out with no traffic coming back accumulated records until disposal. The reviewer described 3 as unbounded growth. It is bounded at 256 by the prefix token space, and stale records could never be mis-attributed because handleResponse prunes before matching, so the severity was overstated. Fixed anyway; it is nearly free. A fourth finding, that the new l10n strings are untranslated in the other 17 locales, is rejected. That is this project's established pipeline: strings are authored in app_en.arb, gen-l10n emits English fallbacks, and untranslated.json tracks the gap, which it now does for these keys. Every string in the app arrived this way, so it is not a defect this branch introduces. The ambiguity test was verified to fail against the pre-fix code. |
3 months ago |
|
|
99b5ea1251 |
fix(#532): a prefixed reply only completes the command that owns the prefix
handleResponse looked up the reply's prefix, and when that matched nothing pending it fell through to "first pending command for this repeater". So a straggler from an expired command could complete an unrelated in-flight one, reporting one command's output as another command's result. The service already carries a correlation token in every command and every reply echoes it. The fallback discarded that. Now a reply that carries a prefix is only ever matched to the prefix's owner; if there is no owner it goes to the unmatched handling added in #528, where an expired command is still named. Only a reply with no prefix at all, which has no correlation token to honour, may fall back to matching by repeater. Not reachable from today's callers: all four pass retries: 1 and the settings refresh awaits each command, so two are never in flight to one repeater. It becomes live the moment anything issues concurrent commands, which any retry work would. The two negative tests were verified to fail against the old logic and pass against the new, so they are a real guard rather than a restatement. The two preservation tests pass either way by design. _registerPending is extracted so the test seam and the real send path cannot drift apart. Part of epic #473, stacked on #528, #529 and #531. |
3 months ago |
|
|
75d08e73ea |
fix(#531): report the timeout window that was armed, not a rounded-up one
The timer ran timeoutMs while the message printed (timeoutMs / 1000).ceil(), so every window in (4000, 5000] announced "timeout after 5 seconds". The owner's 0-hop window was 4074 ms and fired at 4.07 s while claiming 5, which is what made the behaviour look arbitrary rather than deterministic: the number shown was never the number used. The service now throws a typed RepeaterCommandTimeout carrying the window that was actually armed, formatted to one decimal. Every existing caller already stringifies the error, so all of them inherit an honest figure without being touched; the CLI screen additionally renders it through a new localized string rather than the generic error wrapper. Tests pin that 4074 reports 4.1 rather than 5, that the new 28748 ms budget reports 28.7 rather than 29, and that two windows inside the same second no longer collapse to the same text, which was the defect's signature. Part of epic #473, stacked on #528 and #529. |
3 months ago |
|
|
83f6360f7e |
fix(#529): give repeater CLI commands their own timeout budget
The CLI timeout used calculateTimeout, which mirrors the firmware's
calcDirectTimeoutMillisFor and estimates ONE-WAY delivery. A CLI command
is a request, an execution and a reply, so the budget was structurally
short.
Measured against rpt-01 on 910.525 MHz / SF7 / BW 62.5k / CR 4:5, where
the old window was 4074 ms: 27 commands sent, 12 replies matched, and 4
of those 12 arrived after the client had already given up, at 6.17 s,
8.28 s, 15.52 s and 20.33 s against a median of 2.75 s.
calculateCliTimeout sums four terms, each with a source rather than a
chosen value:
outbound leg calculateTimeout, which is what it actually models
cliReplyDelayMs 600, firmware CLI_REPLY_DELAY_MILLIS, unconditional
reply leg the reply is a second packet the ACK formula omits
retrieval budget replies are pull-based; the radio raises MSG_WAITING
and the app must ask, granting itself 5000 ms per
attempt across 3 retries
The retrieval term is derived from the sync constants rather than
restated, so the command timeout cannot drift below the layer it depends
on. That is the #530 invariant holding by construction, not by two
numbers being maintained in agreement.
Execution time is deliberately not modelled. The same verb, wifi on 30,
returned in both 2.31 s and 20.33 s, so it is not a per-command constant
that could be tabulated. The retrieval term carries that tail.
The reply leg uses physics only. The predictor is trained on
direct-message ACK latency, so asking it about a CLI reply leg would be
extrapolation; that is #534 and #535, not this change.
Tests cover the construction, the never-below-retrieval invariant, growth
with path length, coverage of the 20.33 s worst case actually observed,
and negatively that the direct-message ACK path is untouched.
Part of epic #473. Does not change the reported duration string (#531)
or the stale-prefix fallback (#532), and does nothing for commands that
draw no reply at all (#541).
|
3 months ago |
|
|
9d690b2051 |
fix(#528): surface late repeater CLI replies instead of discarding them
A repeater reply that arrived after its command's window closed hit `if (commandId.isEmpty) return;` in RepeaterCommandService.handleResponse and was dropped with no log and no UI. Since the command timeout can be shorter than the app's own message-retrieval budget, that made "the command ran but the response was never reported" the normal outcome rather than an edge case, and it affected every repeater screen, not just the CLI one. RepeaterCommandService now remembers a timed-out command's prefix for two minutes, so a reply arriving afterwards can be attributed to the request it answers. Replies that reach no waiting command are handed to a new onUnmatchedResponse sink and logged through appLogger. No path through handleResponse returns without either completing a command, surfacing the payload, or logging why it could not. The CLI screen renders these as a distinct history entry naming the original command and how late it was. The settings screen applies the value if it is a `get` reply and tells the user it arrived late. repeater_status_screen already parsed responses independently of the service, so it had no silent-loss path to fix. Part of epic #473. Does not change the timeout window itself (#529), the reported duration (#531), or the stale-prefix fallback (#532). |
3 months ago |
|
|
583e513fc9 |
fix(#470): replace em-dash in apply comment (CI em-dash guard #464)
The #470 comment used an em-dash, failing the analyze job's no-em-dash guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
1f000c7916 |
fix(#470): import must never touch broker enabled state
#456 removed enabled from the profile template but the apply path still re-enabled via wasLive, carrying over the prior enabled state on import, against the explicit directive that import brings in NEITHER enabled nor disabled. Firmware already force-disables a slot on any field write (#53); apply now passes enable=false and never re-enables. The slot is left disabled and the operator re-enables intentionally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
fb649e746d |
feat(#553): Disable (not Hide) clears all experimental toggles
Hiding the section left enabled features running invisibly. The action is now 'Disable experimental features': disableExperimental() turns off every experimental flag (CoreScope observer counts, future ones) AND re-locks the section in one atomic write. Also fixes the subtitle to say 'Build row' (the #553 anchor), not 'version row'. Part of the #509/#553 experimental section. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
9e3500a9a9 |
feat(#550): show observers + observations; top-banner refresh feedback
Badge stays observers-primary; tooltip/long-press now read 'N observers . M observations' so they reconcile with CoreScope's 'Observations (N)' feed (the two are distinct metrics: 15 distinct observers vs 34 total sightings). Refresh feedback moved from a bottom snackbar to an auto-dismissed top MaterialBanner, out of the way of the composer. Part of #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
da322d79d9 |
chore(#550): instrument CoreScope path for joint E2E diagnosis
Info logs at each step (0xC6 reply match, hash, CoreScope GET url + result, stored count, message-not-found) so the app debug log shows exactly where the badge chain breaks. Part of #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
2919329265 |
feat(#550): CoreScope orchestration + Experimental toggle + observer badge
After a channel send, when firmware advertises 0xC6 (cap2 0x08 + ver>=22) AND the owner enabled the feature, the connector queries the packet hash, then CoreScope for observer_count, and stamps it on the message (background, best-effort). Adds the coreScopeObserverCountEnabled AppSettings flag, a gated switch in the #509 Experimental section (disabled until the radio supports the capability), and a distinct cloud badge next to the radio-heard count. All inert until the firmware PR (#611) lands. Completes the client side of #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
d79dfffad3 |
feat(#549): CoreScope HTTP observer-count query service
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope instance (default map.okimesh.org); returns observer_count or null. Never throws, so the chat UI degrades silently to radio-only when offline or the hash is unknown. Part of epic #524. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
567745db18 |
feat(#509): hidden 7-tap Experimental unlock + settings section
7 taps on the About version row (rolling ~3s window) reveal a neutral 'Experimental' settings category; persisted on AppSettings, survives restart, re-hidden from a switch inside the section. Empty of toggles for now (Fast Sync #118, CoreScope #524 land into it later). Countdown snackbars after tap 4; already-unlocked feedback. Version-text tap is absorbed so it counts without opening the About dialog. Agent: QuietSnow (session 31eaba02) |
3 months ago |
|
|
f5441e527c |
fix(#505, #506): per-radio block storage, drop the accidental global list
Children of #471 (parent stays open for AAB hardware validation, #507). The block list was one global unscoped list on the phone, unioned onto every radio on connect. A stale block for one of the owner's own radios therefore rode onto every fresh/erased radio, and clearing a radio never stuck: any other radio still holding it re-seeded the global list on connect, which re-pushed it back. - #505 block_store.dart: scope keys/names by connected device key (like the app's other stores); dropLegacyGlobal() deletes the legacy block_keys_v1/block_names_v1 (drop-and-start-fresh, owner-approved). No global list. - #505 block_service.dart: load() drops the legacy global and starts empty; loadForDevice(deviceKey) swaps the in-memory set per radio and runs the #250 self-heal. All mutating ops serialized through a Future chain so loadForDevice and importKeys (different connector frame handlers, both unawaited) cannot interleave and wipe each other. - #506 meshcore_connector.dart: the device-key hook loads the connected radio's list, so the offload union reconciles within that one radio. Existing radio-side firmware blocks left untouched (no auto-CLEAR on migration, owner-approved). Tests: per-radio isolation, clear-sticks across reconnect, legacy-drop, disconnect-clears, load/import race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
3 months ago |
|
|
828aa634c7 |
fix(#425): remove em-dashes from doc comments (CI em-dash guard #464)
|
3 months ago |
|
|
ae3792e6ee |
fix(#425): track actual elapsed time in _cancellableBackoff (Gemini review)
|
3 months ago |
|
|
09c6b911b4 |
fix(#425): retry transient 5xx on translation-model download with backoff
A transient upstream 503 (e.g. Hugging Face) hard-failed the model download and surfaced it as an error (#229). The download requests (HEAD, single GET, range GET) now go through sendModelDownloadWithRetry: 5xx/429 responses and network exceptions retry with bounded exponential backoff (1..30s, <=5 attempts, honors Retry-After, cancellable); terminal 4xx (e.g. 404) fail immediately. The retry logic is a pure injectable top-level function, unit-tested (503-then-200, persistent-503, 404-no-retry, network-exception, cancel). Manual retry after a sustained failure is the existing "Download model" button (re-invokes the download); no new UI added. Build of epic #422. |
3 months ago |
|
|
3b39a16edd |
chore(#462): remove em-dash characters from test/ (and lib top-up)
Follow-up to #457. Swept the em-dash character (U+2014) out of the test tree (test descriptions and comments) and cleaned 8 em-dashes that landed in lib comments via the #456 refactor after #457 merged, so the tree is back to zero. Same rules: replaced with commas/colons/periods, preserved the lone "no data" glyph placeholders, left non-English ARB untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 months ago |
|
|
48a9d79d5a |
refactor(#456): capability-section schema (v2); broker enabled not a profile field
- ConfigProfile becomes sectioned: wifi + mqtt (region/status_interval/brokers). schema_version -> 2. Parser/enumerator read sections; same firmware-key writes, so apply/screens unchanged. Future radio/repeater/companion sections slot in. - Broker 'enabled' removed from the profile schema: enabling a broker is the operator's runtime call (firmware ships slots disabled, opt-in #262). Apply preserves the device's current enabled state; parser rejects broker 'enabled'. 689 tests pass, analyze clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
4 months ago |