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>
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>
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>
iOS has no system Back. The chat had a hamburger in place of the back arrow, PopScope(canPop: false) switched off the edge swipe, and the keyboard covered the bottom bar with nothing to dismiss it.
Add a back arrow to the channel and the line-of-sight map, keep the edge swipe on pushed screens on iOS, and dismiss the keyboard by tapping the channel's messages or dragging either chat's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Route into the app on return from settings only when the scanner is the current route again, the same condition the connection listener uses. A popping route is outside the navigator's present set, so the settings route does not block it.
The share tests now wait frame by frame on their own condition instead of a fixed 2-second window, and the persistence checks were re-proven to fail with persist: false.
Correct a stale comment: the Experimental unlock is on the Device Info Build row, not About.
Agent: BrightDog (session 38fb0e08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
Task 750.1 of the contact-naming plan: model and storage. Stock MeshCore has a per-node CUSTOM NAME field and keeps the original underneath; Offband could not rename a contact at all.
Contact gains receivedName (the name the mesh gave us, wire and storage) and customName (the user's label, client-side only). name becomes a getter returning customName ?? receivedName, so all 85 existing display reads stay correct and a future read cannot forget. Empty and whitespace-only custom names count as unset at both the getter and the store, so clearing the field cannot leave a contact blank with no way back.
The constructor and copyWith take receivedName, which made the rename a compile error at every write site. The compiler enumerated 23 of them across 5 lib files and 11 test files; none was found by reading. The advert handler is the one that matters: receivedName now updates while customName is untouched, so an advert improves accuracy without moving what the user sees. That was the defect making a client-held name impossible.
customName is deliberately NOT in contact_store. That record is rebuilt from radio contact-sync data where no custom name exists, so a save after a sync would wipe it. It lives in contact_settings_store keyed by pubkey hex, which a sync cannot reach.
Both stores persist receivedName, never the getter. The plan's correction on #750 found that persisting the getter is one-way data loss: the custom name would be read back as the received name and the real one destroyed with no advert able to restore it. The ?? 'Unknown' fallback is kept with a comment, since the rename is exactly the edit that drops a trailing fallback and would crash the load for a legacy row.
19 tests, positive and negative. T31 and T32 were falsified before being trusted: breaking the persist and the fallback made exactly those two fail and nothing else.
Gates: analyze --fatal-infos --fatal-warnings clean, dart format 0-changed, 1305 tests passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The badge's player reads d=, o=, b= positionally, in that order, so RTTTL from elsewhere that lists them otherwise (owner's Take On Me: o=5,d=8,b=160,b=160) is refused as a stray colon, and firmware's 21-column words do not say why. When the header is the only fault, the editor now says the badge reads d=, o=, b= in that order, each once, and offers Fix order. The fix refuses to pick between two different values for one key, and must itself pass rtttlProblem, so it never hides a second problem.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
* 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>
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>
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>
A firmware build can ACK a broker enable/disable as success without applying it
(HV4; meshcore-firmware#179). The quick toggle and the editor save previously
trusted the ACK and showed a success snackbar, so an ack-but-no-op looked like
it worked.
Both paths now SET, wait a short settle, re-read the slot, and report what the
device ACTUALLY did:
- applied -> confirm
- notApplied -> warn "possible firmware issue"; the editor stays and re-seeds to
the device's true state instead of popping a false success
- unverified -> re-read failed (e.g. an enable that rebooted the device)
Every device is treated identically: the UI reflects the re-read, never assuming
a change took that the device didn't confirm.
- BrokerConfig.classifyApply pure classifier + BrokerApplyOutcome (unit-tested)
- ObserverConfigService.applySettleDelay
- wired into mqtt_brokers_screen quick toggle + broker_editor_screen save
- widget test for the ACK-but-not-applied path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware feedback: JWT owner defaults to the device pubkey and IATA override
is optional by definition, so gating a save on them is wrong -- they have
under-the-hood firmware defaults.
BrokerConfig.enableError now checks only the structural fields with no default:
URL present, port in range. The JWT field checks are gone; the firmware enforces
JWT completeness with its defaults, and a genuinely-bad enable surfaces the
firmware's reason via the failure messaging added earlier. The editor's pre-save
check and the list's quick Enable both follow.
Tests updated to the new contract (a JWT broker with blank owner/audience now
enables; a structurally-complete quick Enable confirms). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On-hardware test feedback:
- Long-press Enable/Disable was silent on success AND failure. It now confirms
on success and, on failure, surfaces the device's reason (lastError) instead
of nothing.
- The client now refuses to quick-Enable a broker that can't work (incomplete
required fields, e.g. JWT without audience/owner) and says why -- so it never
reports "enabled" on a broker that won't run. (Firmware should reject too.)
- The editor blocked a DISABLE on blank/invalid fields. Validation now gates
ENABLING only -- disabling a slot with partial values is fine; it won't be
active.
Validation rules shared via BrokerConfig.enableError (editor pre-save + list
quick-Enable). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The broker pool gets its own screen, reached from a "MQTT brokers" row in the
Observer pane (replacing the read-only inline list and its stale "pending
firmware support" note).
- MqttBrokersScreen: a tile per broker (tap -> editor, long-press -> Enable /
Disable / Edit / Clear), and a + FAB that opens the next empty slot (disabled
when all 10 are full) -- matching channels_screen's FAB-add pattern. Each
action re-reads only the affected slot, never the full pool dump.
- Observer pane: the broker section is now a navigational row showing
"N configured - M enabled"; the editor is wired in end-to-end.
3/3 screen tests green; full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The edit UI for one broker slot. Staged-save: controls edit local state; Save
sends only the CHANGED fields through saveBroker (enabled written last), so a
partial save can't leave a live-corrupt broker.
- Fields: url, port, transport, auth_type, username, write-only password,
topic_prefix, iata_override, jwt_* (when auth=jwt), ca_cert (when tls/wss),
plus the enabled toggle.
- Validation before any SET: port 1-65535, jwt audience+owner when auth=jwt.
- Write-only password: sent only when typed; blank keeps the stored secret.
- Refresh: single-slot re-GET (getBroker) + reseed, with a dirty discard guard.
- Back button guards unsaved edits (PopScope -> Discard? dialog) -- Gemini's
state-loss finding.
4/4 widget tests green. Brokers list screen + Observer-pane wiring next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three observer-delta fixes (per the #80 sync diagnosis / G2):
- A: settings screen gates the Observer category via context.select on the
supported flag, not a connector-wide context.watch -- the watch rebuilt the
whole settings screen on every sync frame, thrashing the UI thread.
- B: a flat Save re-reads only the flat settings (refresh includeBrokers:false),
never the 84-frame broker pool -- no BLE re-flood for a display toggle.
- C: the initial observer read is deferred until the device's channel/contact
sync settles, so it never competes with that sync for BLE.
TDD: B (no OCFG_BROKERS on a flat refresh) + C (refresh deferred while syncing,
fires once idle). Full observer suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The G2 BLE log showed the config protocol works (GETs return values; the broker
dump starts) but the firmware's broker dump stalls without BROKERS_END, so
getBrokers() times out. The client was treating that as a TOTAL failure --
discarding the 7 flat settings it read cleanly and blanking the pane.
Now the broker pool loads independently: refresh() builds + shows the flat
config even when getBrokers fails, flags brokersUnavailable, and the pane shows
the broker section as "unavailable" instead of going stale. Flat settings stay
usable. TDD red->green; full observer suite (39) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the 2 MINOR findings from the G1 re-review (#76):
- MINOR-A: ObserverConfigService.refresh() no longer wipes lastError / clears
stale on a PARTIAL read. A null from any getFlat is a failed read; the
snapshot is marked stale and the error kept (error-visibility).
- MINOR-B: the settings pane validates status_interval against the firmware
range (10-3600) before sending; an out-of-range value is not put on the wire
and the user is told.
TDD: both tests RED first (current sent '5'; refresh wiped the error), GREEN
after. Full observer suite (37 tests) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hardened the staged-save pane (settings_screen capability gate already correct):
- Normalize an unexpected device rotation to a valid SegmentedButton segment
{0,180} so the control always has a valid selection and never echoes an
out-of-range value back on save.
- Disable Save during a refresh (no save/refresh race on the controllers).
- digitsOnly formatter on status-interval (firmware still range-checks).
- Keys on the SSID/password fields for robust widget-test targeting.
4 widget tests pin the security-sensitive behavior: saves send ONLY changed
keys, a blank password keeps the stored secret, an entered password is sent as
wifi.pwd then the field clears, rotation is normalized, stale/error surfaced
(SAFELANE §6). Full observer suite (33 tests) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TcpScreen.initState reads AppSettingsService from context
to pre-fill host/port fields, but the test helper only
provided MeshCoreConnector. Switch to MultiProvider so
AppSettingsService is also in the widget tree.
- Replace thin MeshCoreTcpManager facade with a proper MeshCoreTcpConnector
that owns TcpTransportService and the frame subscription, mirroring
MeshCoreUsbManager. The connector no longer holds a raw TcpTransportService
or a _tcpFrameSubscription field.
- Remove hardcoded default host IP from TcpScreen (keep port 5000 hint).
- Disable connect button during scanning state, not just connecting state.
- Fix tcpPortLabel mistranslated as nautical "port/harbor" in de, it, pt,
nl, sv, sk, sl, zh; fix corrupted Slovak tcpPortHint ("5 000" → "5000").
- Remove unused tcpStatus_connecting string from all 15 locale arb files
and all generated app_localizations_*.dart files.
- Add extendedPadding to TCP screen FABs to match USB screen.
- Add Key to connect button; update tests to use byKey and assert
onPressed == null when button is disabled during scanning.
- Implemented TCP transport service for native platforms.
- Added TCP connection screen with input fields for host and port.
- Integrated TCP connection options into the scanner and USB screens.
- Updated localization files for new TCP-related strings.
- Added tests for TCP connection flow and error handling.
- Enhanced USB screen to include TCP connection option.
- Improved layout to ensure no overflow in narrow widths for scanner and USB screens.
- Rewrite UsbScreen to mirror ScannerScreen patterns (status bar,
tap-to-connect port list, bottom FABs, SnackBar errors)
- Extract MeshCoreUsbManager from MeshCoreConnector for cleaner
USB transport ownership
- Add debug logging throughout USB connection flow (connector,
manager, web/native services)
- Print debug logs to console in debug mode even when app debug
log setting is disabled
- Localize remaining hardcoded strings (Web Serial Device fallback
label, USB status bar keys, companion firmware timeout hint)
- Fix Swedish misspelling in translations (stöderliga → stödda)
- Guard Linux notification init against missing D-Bus session bus
- Fix SNRIndicator hit-test error by adding minimum size constraints
- Update USB flow tests for new UI patterns
- Updated the _friendlyErrorMessage method in UsbScreen to provide more user-friendly error messages based on specific PlatformException codes.
- Added localized error messages for various USB-related errors, improving clarity for users.
- Modified the UsbSerialService to rethrow exceptions instead of throwing StateError, allowing for better error propagation.
- Updated the usb_flow_test to reflect changes in the USB display label behavior, ensuring the test accurately describes the functionality.
- replace Android USB dependency with app-owned USB host implementation\n- restore BLE-first scanner flow with USB secondary action\n- tighten Web Serial key handling and disconnect logging\n\nTODO (follow-up):\n- review non-English localization copy for tone and consistency\n- trim remaining unused/awkward localization strings introduced during USB UI changes