The repeater "Path hash mode" dropdown showed raw mode numbers (0/1/2). Match
the companion device's selector (settings_screen.dart _PathHashSizeTile) so the
options are self-describing: "1-byte · max 64 hops" / "2-byte · max 32 hops" /
"3-byte · max 21 hops".
Underlying value (mode 0/1/2 = 1/2/3-byte) and the `set path.hash.mode N` wire
command are unchanged — labels only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- pubspec 1.1.2+30 -> 1.1.2-beta.1+31: the app self-identifies as the public beta
heading toward 1.1.2 final.
- scripts/package-windows.ps1 (#96): builds the distributable zip and BUNDLES the
VC++ runtime DLLs (msvcp140 / vcruntime140*) that Flutter's Windows build omits,
so the zip launches on a clean machine without the redistributable installed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Platform-adaptive export on the BLE/frame log screen: the system share sheet on
mobile (the priority path), or open the logs folder in the file manager on
desktop. Web-safe (defaultTargetPlatform, no dart:io); flushes the file first so
it is current. Completes #97.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Always-on file logging so users/support can retrieve a log without enabling
anything in the UI. No-op on web (no filesystem).
- FileLogWriter: rotating file writer (size cap + max files), single + batch
writes, unit-tested against a real temp dir.
- FileLogService: singleton initialised in main(); resolves
getApplicationSupportDirectory()/logs, queues lines, periodic 2s batched
flush, bounded queue, never crashes the app on an IO error.
- AppDebugLogService.log + BleDebugLogService.logFrame write every entry to the
file (app logs always, independent of the in-app viewer's enable gate).
Location per the #97 decision: standard support dir on all platforms (Windows
%APPDATA%\app.offband.meshcore\logs). Still TODO before recompile: access UI
("Open logs folder" on desktop / "Share logs" on mobile).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Was 1.1.1+18. Tags already referenced 1.1.2 (v1.1.2-ble-test/-queuediag) and the
rc8/rc9 APKs shipped +28/+29 via --build-number, so the pubspec build number had
drifted. +30 is the next build after the +29 that shipped. Tag v1.1.2 at release.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the additive OCFG_BROKERS dump fields from firmware #172/#173
(backward-compatible — absent fields read as today's enabled/disabled):
- BrokerRuntimeState (`state`) + BrokerLastError (`last_error`) + a pure
BrokerConfig.status mapping -> Connected / Connecting / Failed (reason) /
Held (no clock|low heap) / Enabled / Disabled; held shown neutral, not error.
- Brokers list shows the real status line + colour instead of just "enabled".
- Editor shows jwt_owner / iata_override resolved defaults (`jwt_owner_resolved`,
`iata_resolved`) as greyed placeholders when the raw key is blank; only writes
the raw key when the user enters a value (blank stays the source of truth).
Sourced from the pool dump (getBrokers); getBroker's per-field GET list is
unchanged pending the firmware GET-ability answer on #173. Unit-tested mapping,
parsing, and backward-compat (9 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A firmware build can ACK a broker enable/disable as success without applying it
(HV4; meshcore-firmware#179). The quick toggle and the editor save previously
trusted the ACK and showed a success snackbar, so an ack-but-no-op looked like
it worked.
Both paths now SET, wait a short settle, re-read the slot, and report what the
device ACTUALLY did:
- applied -> confirm
- notApplied -> warn "possible firmware issue"; the editor stays and re-seeds to
the device's true state instead of popping a false success
- unverified -> re-read failed (e.g. an enable that rebooted the device)
Every device is treated identically: the UI reflects the re-read, never assuming
a change took that the device didn't confirm.
- BrokerConfig.classifyApply pure classifier + BrokerApplyOutcome (unit-tested)
- ObserverConfigService.applySettleDelay
- wired into mqtt_brokers_screen quick toggle + broker_editor_screen save
- widget test for the ACK-but-not-applied path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The BLE/USB self-info retry was an unbounded 3.5s hammer -- the Web path caps at
3 and skips while syncing; BLE had neither. On a slow device handshake it churned
the connect and starved the contact sync, hanging the contact-load bar. Inherited
from upstream zjs81/meshcore-open (git blame; zero Offband commits).
- Replace the unbounded 3.5s Timer.periodic with a self-rescheduling backoff:
3.5s -> 7 -> 14 -> 28 -> 56s, then a steady ~60s keep-alive. Never hard-stops --
a slow/recovering device still gets prompted.
- Add the Web path's skip-while-syncing guard on BLE (per-tick skip, never
permanent: the timer keeps rescheduling, so a settled sync lets the next tick
send).
- Decisions extracted as pure, tested helpers: nextAppStartRetryDelay(attempt)
and shouldSendAppStartRetry({connected, awaitingSelfInfo, syncing}).
- Disconnect teardown (Gemini's amendment) was already present (:2501 / :2374).
Gemini plan-review: Proceed, no blockers. 6 new helper tests; full suite 299 green.
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>
The write half of the broker editor, against RedCreek's confirmed field-at-a-time
contract. The existing 0xC0 key/value command already carries the broker keys --
no new wire codes -- so the codec comment at observer_config_client.dart:166 is
stale, not a gap.
- saveBroker(): disable a live slot first, write each changed field, write
`enabled` LAST. Stops at the first ERR/timeout and reports the failed field --
a partial save leaves the slot disabled, never live-corrupt (activation guard).
TDD: ordering + partial-failure tests.
- clearBroker(): mqtt.broker.<slot>.clear.
- getBroker(): single-slot re-GET via 14 per-field GETs (recovery / post-save
read), not the 84-frame pool dump. Password is write-only -> best-effort.
Key spellings (mqtt.broker.N.enabled / .clear, scalar field GET) are inferred
from the existing scheme; they're the one thing the joint firmware test confirms.
12/12 service tests green. UI (brokers screen + editor) next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware answers RESP_CODE_ERR for an empty/invalid channel index, but
_handleErrorFrame never advanced the in-flight channel sync. So every empty slot
waited out the 2s timeout + 3 retries (~8s each): a device with a few populated
channels out of a large capacity took minutes to sync (~40 slots requested, ~29
empty -> ~4 min) before the real channels (which answer CHANNEL_INFO) finally
synced in the last couple seconds.
Now an ERR that lands while a channel GET is in flight (and no generic-ack
command is waiting -- that one is order-correlated to the ERR first) advances to
the next slot immediately, mirroring the CHANNEL_INFO success path minus adding
a channel. The decision is pinned as a pure static predicate and tested,
matching the connector's existing decision-helper test convention.
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>
main.dart wiring verified correct (no change): the singleton MeshCoreConnector
(main.dart:38 -> field -> provided by value) is the SAME instance passed to
ObserverConfigService, so the service watches the live transport. Plain
ChangeNotifierProvider (not a proxy) is right -- the connector instance is
stable; only its state changes, which the service reads live. Lazy-safe: the
constructor has no side effects.
This test pins that contract: the service is reachable through Provider and a
mutation to the shared connector's caps flips the Observer gate (supported).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gemini MAJOR #4 (offset unverified / short-frame robustness):
- Offset VERIFIED against firmware MyMesh.cpp: the device-info tail is three
consecutive out_frame[i++] writes -- client_repeat@80, path_hash_mode@81,
offband_caps@82. Bytes 80/81 are shipping (path-hash), so caps@82 is correct
by adjacency, not a guess.
- Extracted MeshCoreConnector.parseOffbandCaps (pure, bounds-checked) so the
guard + offset are unit-testable; _handleDeviceInfo delegates to it.
- 4 tests pin: pre-v14 short frame -> null, len-83 reads byte 82 verbatim,
observer bit round-trips supportsConfig, hostile truncated frames never OOB.
Live observer node confirms end-to-end at G2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The models are the existing draft (1983ed9); Gemini flagged nothing in them.
This pins the defensive behavior they give untrusted wire data: enum fromWire
fallback, numeric tryParse fallback, secret-presence flag, SecretField
three-state, copyWith. 7 tests, all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greens A2's adversarial tests, fixing the Gemini findings:
- BLOCKER #1 (race): a chain-lock (_serialized) serializes every transport
round-trip, so overlapping refresh()/setFlat() can no longer cross responses
(firmware has no request id; responses correlate by order).
- MINOR #6 (secret leak): error messages redact secret key names
(wifi.pwd / *.password / *.pwd -> "<secret>").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 tests for ObserverConfigService via a fake connector (subclass override of
the 4 members the service uses -- no production change). 2 green (capability
gate, happy-path setFlat). 2 RED BY DESIGN, documenting the Gemini findings to
be fixed by S1 (#73):
- BLOCKER #1: overlapping getFlat/setFlat cross responses (single-flight is
assumed, not enforced) -- setFlat receives the GET's value, not its ACK.
- MINOR #6: a secret key name leaks into the error string
("SET wifi.pwd failed: ...").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 tests for ObserverConfigClient / BrokerListDecoder. 9 green (builders,
response parsing, happy-path decode, malformed-NUL, garbage-numeric, stray-KV).
2 RED BY DESIGN, documenting the Gemini findings to be fixed by C1 (#72):
- DoS: BrokerListDecoder retains 256 slots from a hostile BROKER_KV flood
(must bound to brokerSlotCount=10).
- out-of-range slot 200 is stored (must be dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- screens/settings/observer_settings_view.dart: staged-save pane for WiFi
(ssid/pwd/enabled), MQTT region (iata/status_interval), display
(always_on/rotation 0|180), with Save (changed keys only) + Refresh +
stale/error banners; the broker pool renders read-only (enable/clear await
the firmware F-task).
- settings_screen.dart: register the Observer category, gated on
ObserverConfigService.supported (re-evaluated as the connector's offband_caps
flips on connect/disconnect).
- connector/meshcore_connector.dart: parse the offband_caps byte (device-info
byte 82, after path_hash_mode) + expose offbandCaps.
- services/observer_config_service.dart: supported computed live from the
connector (version gate + cap bit).
- main.dart: provide ObserverConfigService.
Strings are English-only pending l10n ARB keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflow the systemDevices.map(...) chain after the .where() removal so
`dart format --set-exit-if-changed .` is clean. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scanner filtered advertisements by device-name keyword
(withKeywords: deviceNamePrefixes), so any MeshCore/Offband device
whose advertised name fell outside the hardcoded prefix list was
invisible to the scanner — even though the official MeshCore app,
which scans by the Nordic UART service UUID, found them. Switch
startScan to withServices: [Guid(service)] and drop the now-redundant
name filter on the already-service-scoped systemDevices fallback.
Device-tested on a Samsung S25: a previously-invisible node now
appears in the scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's `dart format --set-exit-if-changed .` step is repo-wide. My A/B/C
commit hand-wrote the .catchError blocks without running the formatter,
and 2 settings views carried pre-existing format drift from #28. dart
format reflows all three (whitespace only, identical logic):
- lib/connector/meshcore_connector.dart (this PR's blocks)
- lib/screens/settings/app_settings_view.dart (pre-existing, #28)
- lib/screens/settings/message_settings_view.dart (pre-existing, #28)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OffbandMesh org board (project #2) had no label->board sync, so issues never populated it (root cause of the 0-item board). Add sync-labels-to-board.yml (adapted from the DifferentWire/Unfocused canonical) wired to board #2's project + status/priority field and option IDs; runs on ubuntu-latest, uses repo secret PROJECT_PAT (classic PAT, project+repo scope). REQUIRES PROJECT_PAT set as a repo secret to function.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
settings_screen action handlers fire-and-forgot or awaited-without-catch several connector calls, and _loadVersionInfo called setState after an await with no mounted guard. Add a mounted guard to _loadVersionInfo; try/catch + error snackbar to the GPS-enable toggles (inline + _editLocation dialog) and getContacts; await syncTime in _syncTime so the 'synchronized' snackbar shows only on success (it previously fired before the await); and in _editLocation's save, capture the messenger before the pop and wrap the setCustomVar/setNodeLocation/refreshDeviceInfo awaits.
Verified non-issues: deleteAllPaths is void, _PrivacySection._apply() already has a try/catch, and exportGPX catches internally and returns gpxExportFailed. Completes #28; error strings English for now (separate l10n sweep).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings onChanged/onTap callbacks fire-and-forgot AppSettingsService Future<void> setters, so a persist failure rejected unhandled and was swallowed. Add a shared persistSetting(context, action) helper that awaits the setter in a try/catch and shows a dismissible error SnackBar, capturing the messenger before the await so it survives a picker-dialog pop. Wrap the 27 onChanged/onTap setter sites in app_settings_view + message_settings_view; setAppDebugLogEnabled and setNotificationsEnabled (which await + show their own confirmation) get an inline try/catch instead.
Covers the two onChanged panes; the settings_screen action flows (GPX export, location dialogs, version-info mounted guard) follow in a second pass, so #28 stays open. Error strings are English for now (separate settings l10n sweep).
Adds a persistSetting widget test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DirectRepeater.ranking added recencyScore (0..1.8M) to the SNR term (1000/dB), so recency dominated (~1 dB == 1 second), contradicting the comment that recency only breaks ties. Scale recency to a 0-999 bonus so SNR (1000/dB) is primary and recency only decides sub-dB ties.
Offset SNR by its floor (+32; SX126x SNR spans -32..+31.75 dB) so every live ranking stays >= 0, above the -1 stale sentinel; otherwise the scaled-down recency leaves live rankings negative and a stale repeater would outrank a live one.
Adds DirectRepeater.ranking tests: SNR-first, recency tie-break, stale=-1, live-always-outranks-stale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
disconnect() never resets the channel store scope (publicKeyHex), so a channel write in the post-disconnect async gap still persists -- to the last radio's scope, and into a different radio's scope after reconnecting (cross-radio bleed). Add if (!isConnected) return; to the UI-triggered persisters setChannelUnreadCount, markChannelRead, setChannelOrder. Chosen over clearing _channels, which would risk a racing mutator persisting an empty list and wiping stored channels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PathHistoryService capped each contact's recentPaths at 100 but dropped any new distinct path once full, so the list froze on the first 100 ever seen and never refreshed with newer/better routes. Evict the lowest-_scorePathRecord entry to make room, keeping the strongest 100. Adds a regression test (full history evicts the weakest to admit a new path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Query FlutterBluePlus.systemDevices on every native platform (was Linux-only)
so a radio already connected to another central -- e.g. Liam's MeshCore app --
appears in the picker and we attach to the existing link. A connected BLE radio
stops advertising, so a scan alone never finds it.
Patch increment for the #37 compact-tile feature (the bump that was owed
inside PR #47). Build +18 stays ahead of rc.3 (+17) so a 1.1.1 build
installs over the sideloaded RC.
Parse a leading @[Name] in the message body and render it as a rounded
pill that sets the name apart from the rest, matching the reference
client. Handles a missing space after the mention. Lives in
TranslatedMessageContent so channel + direct chat both get it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Clock setting (System / 12h / 24h) in App Settings; chat times follow it + locale.
- Channel tile: always-on metadata row - time + hops on inbound, time + cell_tower
heard count + status on outbound - decoupled from the message-tracing toggle. The
via-path stays behind tracing.
- Drop the auto reply-to block and the most-recent-sender guess; @mentions now
render inline in the message text.
- Direct chat: same clock-aware time formatting.
Closes#37
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the typed text anywhere in the shortcode name (not just the prefix),
ranked exact > prefix > substring, so `:check` surfaces the checkmarks (✅ is
`white_check_mark`). Add check/checkmark/tick aliases for ✅ in a separate,
regen-safe map the chat + channel composers consume.
Closes#45
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more connector silent-failures from the review. requestRadioStats had an empty 'catch (_) {}' (now logs). setChannelUnreadCount fired unawaited(saveChannels) with no error path (now .catchError + logs). Verified: setContactUnreadCount calls a void debounced store method -- no call-site Future (Gemini false positive); the actual swallow is inside unread_store's debounce timer, flagged separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BLE onValueReceived.listen had no onError, while USB (L1557) and TCP (L1669) both attach one that logs and gracefully disconnects; an error emission on the BLE notify stream was unhandled. Add a matching onError. Also await sendMessageWithRetry in the reaction branch of sendMessage -- the normal path already awaits it; the reaction path's unawaited call swallowed failures, contradicting its own 'same as normal messages' comment.
Verified, not assumed: Gemini's 'disconnect() not try-caught' was a FALSE POSITIVE (already wrapped at L2484-2489); 'deleteAllPaths silent void' is a sync in-memory clear (no async failure path). flutter analyze clean; Gemini-reviewed with no critique of these two changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>