mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
Refactor code structure and remove redundant sections for improved readability and maintainability
This commit is contained in:
+362
@@ -0,0 +1,362 @@
|
||||
# MeshCore Open — Bugs Found (Web build, manual QA)
|
||||
|
||||
Session: 2026-06-12 · Build served at `http://localhost:42751/` (Flutter web, debug/DDC) · Browser: Chrome
|
||||
|
||||
Each entry: **Severity** · where · what · repro · expected.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
Fixes for BUG-1..4 applied on 2026-06-12 (analyze clean). **BUG-2/3 VERIFIED FIXED live:** USB device `VID:239A PID:8029` now connects on web — console shows `Open: vendorId=0x239a uartBridge=false (DTR left default)` → `Got SELF_INFO` → `connectUsb: complete`, no "device has been lost". Continuing to test the now-reachable connected screens. See "Fixes applied" at the bottom.
|
||||
|
||||
## Open bugs
|
||||
|
||||
### BUG-1 · Medium · USB connect — raw browser exception leaked to UI on picker cancel
|
||||
- **Where:** "Connect over USB" screen (`usb_screen.dart`), tap **Select a USB device** → Web Serial port picker → dismiss/cancel without choosing a port.
|
||||
- **What:** A red error snackbar shows the raw browser API string: `NotFoundError: Failed to execute 'requestPort' on 'Serial': No port selected by the user.`
|
||||
- **Why it's a bug:** (1) Cancelling the port picker is a normal user action, not an error — it should be silent (or a neutral "No device selected" message), not a red error toast. (2) Even for real failures, leaking a raw JS `DOMException` string is poor UX. The catch handler should map known cases (user cancellation) and present friendly copy.
|
||||
- **Expected:** Cancelling shows nothing or an info-level message; the UI never prints raw exception text.
|
||||
- **Root cause (confirmed in source):** `usb_screen.dart:396 _friendlyErrorMessage()` maps `PlatformException`/`StateError`/etc., but the web cancel path throws a JS `DOMException` (`NotFoundError`) that matches none of the branches, so it falls through to `return error.toString();` (line 441). A friendly `l10n.usbErrorNoDeviceSelected` string already exists (line 428) but is only matched for a native `StateError` containing `'No USB serial device selected'`, not the web DOMException. Fix: detect the web no-port-selected case and route it to the existing friendly string (or suppress entirely).
|
||||
|
||||
### BUG-2 · HIGH · Web USB connect fails with misleading "Timed out waiting for SELF_INFO" — read-pump error is dropped by a subscription race
|
||||
- **Where:** `usb_serial_service_web.dart` + `meshcore_connector.dart connectUsb()`. Repro: on web (Chrome), connect to a USB serial device (observed with `Web Serial Device VID:239A PID:8029`, an Adafruit/nRF52840 board).
|
||||
- **Observed (console, happens every attempt):**
|
||||
1. Port opens OK: `USB serial opened port=Web Serial Device (VID:239A PID:8029)`
|
||||
2. **Immediately:** `_pumpReads error: NetworkError: The device has been lost.` → `_pumpReads: ended` — the read stream dies the instant it opens.
|
||||
3. Connect logic ignores that and proceeds: `requesting device info…`, writes TX frames, `ChannelSync Starting sync for 40 channels`.
|
||||
4. Nothing can ever be read back → SELF_INFO + ChannelSync retry/timeout for ~7s.
|
||||
5. Ends with `USB connection error: Bad state: Timed out waiting for SELF_INFO during connect` → disconnect. User sees a generic timeout error, not the real cause.
|
||||
- **Root cause (confirmed in source):** a **subscription-timing race on a broadcast stream**:
|
||||
- `_frameController` is `StreamController<Uint8List>.broadcast()` (`usb_serial_service_web.dart:24-25`). Broadcast streams **do not buffer** — events emitted with no listener attached are silently discarded.
|
||||
- `_usbManager.connect()` starts `_pumpReads()` fire-and-forget (`usb_serial_service_web.dart:114`). On this device `_pumpReads` errors instantly and calls `_addFrameError()` (line 387/393).
|
||||
- But `connectUsb()` attaches its error listener **only after** `await Future<void>.delayed(200ms)` (`meshcore_connector.dart:1609`), then `frameStream.listen(onError: → disconnect(), onDone: → disconnect())` (lines 1610-1620). The read-pump error fired during that 200ms gap, so the `onError → disconnect` fail-fast path **never runs**.
|
||||
- With the safety net disarmed, connect falls through to `_waitForSelfInfo` (3s) + retry (3s) and throws the misleading `Timed out waiting for SELF_INFO during connect` (line 1647).
|
||||
- **Expected:** A read-stream failure during connect should abort immediately with the real cause ("USB device disconnected / lost"), not a 7-second generic SELF_INFO timeout.
|
||||
- **Fix directions:** attach the `frameStream` listener (or otherwise observe transport health) *before* the read pump can emit — i.e. before/at port-open, not 200ms later; OR latch the last transport error in the service and have `connectUsb` check it; OR make connect race `_waitForSelfInfo` against a transport-error future. Remove/justify the unconditional 200ms delay that opens the race window.
|
||||
|
||||
### BUG-3 · Likely device-level root cause for BUG-2 · DTR assertion may reset/lose nRF52840 (Adafruit, VID 0x239A) boards
|
||||
- **Where:** `usb_serial_service_web.dart:285-295 _openPort()`.
|
||||
- **What:** Immediately after `open()`, the code asserts `setSignals({dataTerminalReady:true, requestToSend:false})` with the comment *"Prevent ESP32 USB-CDC reset"*. That logic is tuned for ESP32. On Adafruit nRF52840 boards (VID `0x239A`, as seen here, PID `0x8029`), toggling DTR is associated with the bootloader/reset line and can cause the device to re-enumerate/reset — which plausibly produces the immediate `NetworkError: The device has been lost.` seen in BUG-2.
|
||||
- **Status:** Strong hypothesis, not yet isolated (would need to test connecting with the `setSignals` call removed/varied for this board). Flagging because the device class that fails (nRF52840/Adafruit) is exactly the one where DTR semantics differ from ESP32.
|
||||
- **Expected:** USB serial open should work for nRF52840-class MeshCore boards on web, or DTR handling should be conditional per device/VID.
|
||||
|
||||
### BUG-4 · Medium · BLE "Scan" on web (no Bluetooth adapter / unsupported) gives zero feedback
|
||||
- **Where:** Scanner screen (`scanner_screen.dart`). Repro: on web with no BLE module (or any web build, since `flutter_blue_plus` doesn't support web), tap **Scan**.
|
||||
- **What:** Nothing happens — no spinner, no "scanning" state, no error toast, no "Bluetooth unavailable on web" message. The button just sits there and status stays "Not connected". (Confirmed by user: "its not connecting on chrome and my computer doesn't have a ble module".)
|
||||
- **Expected:** Either disable/hide BLE scan on web (the app already gates non-Chrome via `ChromeRequiredScreen`; Chrome+web still can't use `flutter_blue_plus`), or show a clear "Bluetooth isn't available in the browser — use USB or TCP" message. Silent no-op leaves the user stuck.
|
||||
|
||||
### BUG-5 · Low/Medium · Notifications never work until manually enabled; every incoming message logs an error
|
||||
- **Where:** `notification_service.dart` — `show()` calls at lines 201/248/306/394/593 are made without ensuring notification permission was granted.
|
||||
- **What (observed in console while connected):** repeated `Failed to show channel notification: Bad state: FlutterLocalNotifications.show(): You must request notifications permissions first` and the same for advert/message notifications. Each incoming channel message / advert triggers one.
|
||||
- **Why:** permission is only ever requested from `app_settings_screen.dart:239` (when the user interacts with that setting). If the user never visits it, `requestPermissions()` is never called, so `show()` throws on web (and would on Android 13+ with denied permission). The errors are swallowed by `try/catch` (no crash), but notifications silently never fire and the log fills with errors.
|
||||
- **Expected:** request notification permission during init / on first connect (or check `areNotificationsEnabled()` and skip `show()` when not granted) instead of calling `show()` unconditionally and relying on a caught exception per message.
|
||||
|
||||
### OBS-1 · RESOLVED (not a bug) · USB-on-web connection dropped after ~4 min (`device has been lost`)
|
||||
- **What:** At 9:25:13 the read pump errored `NetworkError: The device has been lost.` → `USB transport error` → clean auto-disconnect back to the scanner.
|
||||
- **Cause:** confirmed by user — they physically dropped/disconnected the radio. So this was a real physical disconnect, NOT a Web Serial stability issue. Positive finding: the app (with the BUG-2 fix) handled an abrupt physical disconnect cleanly and returned to the scanner.
|
||||
|
||||
### BUG-6 · Medium · Battery Chemistry setting permanently disabled on USB (and TCP) connections
|
||||
- **Where:** App Settings → BATTERY → Battery Chemistry. `app_settings_screen.dart:610-611` gates the control on `connector.deviceId != null`; `meshcore_connector.dart` only ever assigns `_deviceId` in the **BLE** connect path (`_deviceId = device.remoteId.toString()`, line 1839, right after `_activeTransport = bluetooth`).
|
||||
- **What:** When connected over USB (verified) — and by the same logic TCP — `connector.deviceId` is `null`, so the Battery Chemistry dropdown shows the subtitle "Connect to a device to choose" and is disabled, even though the device is fully connected (header shows `088EDAA0 · Connected`, radio stats live).
|
||||
- **Why it matters:** USB/TCP users can never set per-device battery chemistry, so the battery percentage indicator uses the wrong voltage curve for their pack. Also user-confusing ("connect first" while connected).
|
||||
- **Note:** The `088EDAA0` shown throughout the UI is a node/public-key-derived identifier, distinct from `_deviceId` (the BLE remoteId). The battery setting keys off the BLE-only `_deviceId`.
|
||||
- **Fix direction:** populate a stable per-device identifier in the USB/TCP connect paths (e.g. the node public-key prefix already used for storage scoping), or key battery chemistry off that same node identity rather than the BLE remoteId.
|
||||
|
||||
### BUG-7 · Low/Medium · Node Name can be saved empty (no validation)
|
||||
- **Where:** Settings → Node Name dialog, `settings_screen.dart:708-744 _editNodeName()`.
|
||||
- **What:** Clearing the field leaves the **Save** button enabled; the handler (line 728-740) calls `connector.setNodeName(controller.text)` directly with no `trim()`/empty check. An empty or whitespace-only name can be written to the device, leaving the node nameless on the mesh (others see a blank contact). Reproduced: cleared field → "0/31", Save still active. (Did not actually save.)
|
||||
- **Compare:** the Radio Settings dialog correctly disables Save on invalid input — Node Name should do the same.
|
||||
- **Expected:** disable Save (or show an error) when the trimmed name is empty.
|
||||
|
||||
### OBS-2 · Watch · Auto-reconnect to cached Web Serial port fails after a physical drop (stale handle)
|
||||
- **What:** After physical disconnects, the app auto-retries the cached port (`web:port:1`) and fails with `NetworkError: Failed to execute 'open' on 'SerialPort': Failed to open serial port` (seen at 9:26:44, 9:34:23, 9:34:53, ~30s apart). It does eventually recover (header shows Connected again), but the cached `SerialPort` handle is often stale right after an OS-level drop.
|
||||
- **Note:** the native USB service explicitly documents this class of problem and avoids caching the serial handle (`_freshSerial()` comment, `usb_serial_service_native.dart:47-50`). The web service caches the port object (`_authorizedPortsByKey`), so reopen-after-drop is more fragile. Partly environmental here (user was physically re-plugging), so logged as an observation, not a confirmed bug. Worth confirming the web reconnect path discards/re-requests the port on `open()` failure rather than retry-looping on a dead handle.
|
||||
|
||||
---
|
||||
|
||||
## Notes / non-bugs
|
||||
- **Radio Settings validation works:** out-of-range frequency (`9999`) shows "Invalid frequency (300-2500 MHz)" and disables Save. Good.
|
||||
- **Graceful disconnect verified:** when the USB transport drops, the app auto-navigates back to the scanner and shows "Not connected" — matches the intended "handle disconnection gracefully" behavior.
|
||||
- **Channel sync starts before handshake completes:** `ChannelSync Starting sync for 40 channels` fires during connect before `SELF_INFO` is confirmed (console 9:07:54). Likely harmless given BUG-2 masks it, but worth confirming the initial-sync pipeline shouldn't wait for SELF_INFO first.
|
||||
- First load on `localhost:39107` rendered a blank white page and the URL later resolved to `localhost:42751`. This appears to be a dev-server startup/port artifact (the Flutter view never mounted on the first port), not an app bug. Flagging only in case the port hop is intentional behavior worth confirming.
|
||||
|
||||
---
|
||||
|
||||
## Fixes applied (2026-06-12)
|
||||
|
||||
- **BUG-3 (device-lost root cause):** `usb_serial_service_web.dart _openPort()` now only asserts `setSignals(DTR=true)` for known USB-UART bridge VIDs (`_uartBridgeVendorIds`: CP210x `0x10C4`, CH340 `0x1A86`, FTDI `0x0403`, PL2303 `0x067B`). Native-USB-CDC boards (nRF52840/Adafruit `0x239A`, etc.) are left untouched, since toggling DTR re-enumerates them. Added a debug log line reporting the detected vendorId and whether DTR was asserted. (The native service already documented this exact NRF52/DTR behavior — confirms the hypothesis.)
|
||||
- **BUG-2 (dropped read-pump error / misleading timeout):**
|
||||
- `usb_serial_service_web.dart _pumpReads()` now flips `_status = disconnected` and latches `_lastError` when the read loop dies unexpectedly, instead of leaving the service reporting "connected".
|
||||
- Added `lastError` getter to both web + native `UsbSerialService` and to `MeshCoreUsbManager`.
|
||||
- `meshcore_connector.dart connectUsb()` now checks `_usbManager.isConnected` right after the 200ms settle delay (before waiting on SELF_INFO) and throws a clear `USB device disconnected during connect: <cause>` using the latched error — failing in ~200ms with the real reason instead of ~7s with a generic SELF_INFO timeout.
|
||||
- **BUG-1 (raw exception on picker cancel):** `usb_screen.dart` added `_isUserCancelledPortPicker()`; `_showError()` now returns silently for picker cancellation (matches the web `requestPort`/"No port selected" DOMException and the native StateError) instead of showing a red toast with raw text.
|
||||
- **BUG-4 (silent BLE scan on web):** `scanner_screen.dart _toggleScan()` now short-circuits on web and shows `scanner_bluetoothWebUnsupported` ("Bluetooth isn't available in the browser. Connect over USB instead.") instead of silently no-opping. New l10n key added to `app_en.arb` and regenerated for all locales (English fallback; pending auto-translation).
|
||||
|
||||
## Fixes applied — round 2 (2026-06-12)
|
||||
|
||||
- **BUG-5 (notifications never fire / error spam):** `notification_service.dart` added `_ensureCanNotify()` — caches whether the platform can actually post notifications and is now the gate on all four `show()` entry points (message/advert/channel/batch-summary). Returns false on web (the plugin has no web backend, so `show()` always threw) and honors `areNotificationsEnabled()` on Android 13+. `requestPermissions()` now refreshes the cache so enabling from settings takes effect immediately. The `cancel()` paths still use `_ensureInitialized()` (unchanged). Net: no more per-message error spam; notifications post when actually permitted.
|
||||
- **BUG-6 (battery chemistry disabled on USB/TCP):** added `MeshCoreConnector.batteryDeviceKey` — returns the BLE remoteId when present (preserves existing BLE-keyed settings) and falls back to the node public key (`selfPublicKeyHex`) on USB/TCP. Both the internal `_batteryChemistryForDevice()` and the App Settings UI (`app_settings_screen.dart`) now key off `batteryDeviceKey` instead of the BLE-only `connector.deviceId`. Battery chemistry is now selectable on USB/TCP, and the battery-% curve is correct for those connections.
|
||||
- **BUG-7 (empty node name savable):** `settings_screen.dart _editNodeName()` Save button is now wrapped in a `ListenableBuilder` on the text controller and is disabled (`onPressed: null`) when the trimmed name is empty; the handler saves the trimmed value. Mirrors the Radio Settings dialog's validation behavior.
|
||||
|
||||
All four files analyze clean. Pending live re-verification after hot restart (BUG-5: no notification errors in console; BUG-6: Battery Chemistry enabled while on USB).
|
||||
|
||||
---
|
||||
|
||||
## Suggestions / further work (2026-06-12)
|
||||
|
||||
### Follow-ons directly implied by the fixes
|
||||
1. **Audit other per-device features for the same BLE-only gap (BUG-6 was probably not alone).** `_deviceId` is set only in the BLE connect path; anything keyed off `connector.deviceId` or `_device?.remoteId` is silently BLE-only on USB/TCP. Grep those usages and verify each works on USB/TCP, or migrate them to `batteryDeviceKey` / `selfPublicKeyHex`.
|
||||
2. **Battery-chemistry key inconsistency (tradeoff in my BUG-6 fix).** I kept BLE = remoteId and USB/TCP = public key to avoid wiping saved BLE settings. Consequence: the *same physical radio* gets two different chemistry settings depending on transport. Cleaner long-term: key everything off `selfPublicKeyHex` (the canonical per-radio identity used for all other scoped storage) with a one-time migration of existing BLE-keyed values.
|
||||
3. **Web reconnect should discard a stale port handle (OBS-2).** After a physical drop, auto-reconnect retry-loops on the cached `SerialPort` with `Failed to execute 'open'` before recovering. The native service deliberately avoids caching the handle (`_freshSerial()` comment). On `open()` failure the web service should drop the cached port from `_authorizedPortsByKey` and re-request (or prompt) instead of retrying a dead handle.
|
||||
4. **DTR allowlist may need expansion (caveat on my BUG-3 fix).** I now assert DTR only for known UART-bridge VIDs (CP210x/CH340/FTDI/PL2303). A future board with an unlisted bridge chip won't get DTR and could reset on open. Consider a per-board config or a user-visible "hold DTR" toggle as a fallback as more hardware is tested.
|
||||
5. **On web, the notification toggles are ON but inert** (same spirit as BUG-4). Now that `_ensureCanNotify()` correctly skips web, the four NOTIFICATIONS switches in App Settings still read as enabled while doing nothing in-browser. Grey them out / annotate "not available in browser" on web. Also `requestPermissions()` still returns `true` on web (fallback `return true`), which is misleading to its callers.
|
||||
|
||||
### Robustness / architecture
|
||||
6. **The connect handshake rides on a `broadcast()` stream + an unconditional 200ms delay** — the exact combo that caused BUG-2. My fix (status flip + liveness check) closes the connect race, but any consumer that subscribes late can still miss early frames/errors. Consider readiness signaling instead of the magic delay, and/or buffering the first frames during connect.
|
||||
|
||||
### UX polish observed
|
||||
7. **Two "Scan" buttons on the scanner empty state** (center button + bottom-right FAB) — redundant.
|
||||
8. **Verify destructive actions confirm before acting.** I intentionally did NOT trigger "Clear Chat", "Delete All Paths", "Reboot Device", or "Manage Repeater" (not my device). Worth confirming each has a confirmation dialog.
|
||||
9. **Telemetry screen** had no obvious "request once" affordance — only autorefresh (interval 20 / qty 10 / Enable). A manual one-shot refresh would help.
|
||||
|
||||
### Coverage gaps (NOT tested this session — unverified)
|
||||
- Repeater management (hub / CLI / settings / status), Line-of-Sight, Path-Trace map, Community QR scanner.
|
||||
- Discovery & Neighbors screens, Map cache screen, Debug log screens.
|
||||
- TCP transport (no TCP device available), USB on native (Android/desktop), on-device translation (LLM).
|
||||
- Contacts search/sort/filter, channel add/reorder, GPX export.
|
||||
|
||||
---
|
||||
|
||||
## Styling observations (2026-06-12)
|
||||
Caveat: only viewed **dark mode** in a **wide desktop browser** (~1298px). Light mode and narrow/mobile widths not assessed.
|
||||
|
||||
**The color system is good — credit where due.** `mesh_theme.dart` is a coherent, semantic palette ("high-contrast slate surfaces with sky-blue accents"): slate surface ramp (`0B1220`→`334155`), sky-blue accent (`0EA5E9`), signal-green reserved for SNR only (`22C55E`), amber warn, red alert, magenta node type, a full ink ramp, and a separate light ramp. Not a generic default-Material look. (Note: the CLAUDE.md claim that MeshPalette/MeshTheme is "not currently wired" is **stale** — `main.dart:204-205` wires `MeshTheme.light()/.dark()`.)
|
||||
|
||||
**Issues are layout/responsiveness, not color:**
|
||||
1. **No max-width constraint on web/desktop (biggest one).** No `ConstrainedBox`/`maxWidth` in `main.dart`; the app is mobile-first and stretches edge-to-edge on a wide window — chat input spans the full ~1300px, list rows are very wide, chat bubbles hug the far edges, settings rows stretch across. Recommend a centered max-width content column (phone-like, ~480–600px) or responsive breakpoints for the web build.
|
||||
2. **App-bar title alignment is inconsistent.** Main tab screens (Channels/Contacts/Map) use left-aligned titles with a device-id subtitle; detail screens use `centerTitle: true` (23 files) or `AdaptiveAppBarTitle`. Reads slightly inconsistent screen-to-screen — worth one deliberate rule.
|
||||
3. **Tall-viewport chat spacing.** Bottom-anchored chat leaves a large empty area at the top on a tall window (related to #1 — no height/width framing for big viewports).
|
||||
4. **Minor:** map node-detail sheet stacked over the older bottom info bar (z-order overlap); the two redundant Scan buttons are also a visual duplication; verify disabled-control contrast is legible (the pre-fix battery dropdown was quite dim).
|
||||
5. **Unverified:** light theme polish (light ramp exists in the palette but wasn't viewed).
|
||||
|
||||
---
|
||||
|
||||
# Retry & Path-Selection Analysis (2026-06-12, code review vs firmware)
|
||||
|
||||
Static review of the ACK/retry/path-selection pipeline (`message_retry_service.dart`, `path_history_service.dart`, `timeout_prediction_service.dart`, connector wiring) cross-checked against the MeshCore C++ firmware at `/mnt/Gaming/meshcore/MeshCore`. Verified against actual firmware source, not just docs.
|
||||
|
||||
**What's correct (verified):**
|
||||
- **ACK-hash algorithm matches the firmware exactly.** Client `computeExpectedAckHash` (`message_retry_service.dart:104-136`) = `SHA256(timestamp[4 LE] ‖ (attempt&3) ‖ text ‖ sender_pubkey[32])`, first 4 bytes as LE uint32. Firmware `BaseChatMesh.cpp:413-419` builds `temp[0..3]=timestamp`, `temp[4]=attempt&3`, `temp[5..]=text` (null *not* hashed — length passed is `5+text_len`), hashed with `self_id.pub_key`. Byte order and endianness round-trip correctly (`RESP_CODE_SENT`/`PUSH_CODE_SEND_CONFIRMED` both LE uint32, `meshcore_connector.dart:5265-5409`).
|
||||
- **Retry is entirely the client's job** — firmware `sendMessage` sends once, no retry loop. Correct division of responsibility.
|
||||
- **Per-contact in-flight serialization** (`_sendQueue`, `message_retry_service.dart:174-208`) is the right idea — see RETRY-1 for why it's not *sufficient*.
|
||||
|
||||
## Open findings
|
||||
|
||||
### RETRY-1 · Medium · Per-contact in-flight cap doesn't protect the firmware's *global* 8-entry ACK table
|
||||
- **Where:** `message_retry_service.dart:174-183` (one in-flight message *per contact*) vs firmware `examples/companion_radio/MyMesh.h:248-250` + `MyMesh.cpp:1100-1103`.
|
||||
- **What:** The firmware's `expected_ack_table` is a **single global circular buffer of 8 entries** with a global `next_ack_idx`, shared across *all* contacts. The client only guarantees ≤1 in-flight *per contact*, so messaging **9+ contacts concurrently** (e.g. several active conversations whose retries overlap, or a broadcast-style burst) puts >8 entries in flight. The firmware then overwrites the oldest slot (`next_ack_idx = (next_ack_idx+1) % 8`) with no rejection — the evicted message's ACK expectation is silently dropped.
|
||||
- **Consequence:** the evicted send never produces a `PUSH_CODE_SEND_CONFIRMED`, so the client times out and retries a message the radio already sent (wasted airtime), and can mark as **failed** a message that actually delivered.
|
||||
- **The code comment is misleading:** `message_retry_service.dart:173-174` says serialization exists "to avoid overflowing the firmware's 8-entry expected_ack_table" — but a per-contact cap does not bound the global table.
|
||||
- **Fix:** enforce a **global** concurrent-in-flight cap (≤8, ideally ~6 for headroom) across all contacts, not just per-contact; or track `next_ack_idx` pressure and back-pressure new sends.
|
||||
|
||||
### RETRY-2 · Medium · Firmware's reported `est_timeout` is silently discarded
|
||||
- **Where:** `meshcore_connector.dart calculateTimeout()` (4379-4408); `message_retry_service.dart:405-415`.
|
||||
- **What:** `RESP_CODE_SENT` carries the device's own `est_timeout` (firmware `MyMesh.cpp:1106-1110`, computed from real `getEstAirtimeFor(...)`). It's parsed into `timeoutMs` and passed to `updateMessageFromSent` as the default `actualTimeout`. But `config.calculateTimeout` is **always** set, so the device value is immediately overwritten on every message — and when the ML model has no data, `calculateTimeout` returns `physicsMax` (line 4407), **never the device-provided value**.
|
||||
- **Why it's a bug:** the docstring at `message_retry_service.dart:405` ("prefer ML prediction, then device-provided, then physics fallback") describes a tier that doesn't exist — device-provided is never used. The physics fallback re-derives the firmware's flood/direct formulas, but `_estimateAirtimeMs` falls back to a hard-coded **50 ms** when radio params (freq/bw/sf/cr) aren't yet known (`meshcore_connector.dart:4341-...`), which can diverge sharply from the device's actual airtime estimate (e.g. SF12). The device already did this math correctly with the real airtime.
|
||||
- **Fix:** use the device `timeoutMs` as the fallback when ML is unavailable, and as the clamp ceiling when ML is present — it's strictly better information than a 50 ms guess.
|
||||
|
||||
### RETRY-3 · Medium · Premature ML timeouts can delete good routes (timeout floor too low + aggressive weight decay)
|
||||
- **Where:** `meshcore_connector.dart _physicsMinTimeout` (≈4360-4375); `message_retry_service.dart:491-538`; `path_history_service.dart:131-141`.
|
||||
- **What:** For direct paths the timeout floor is `airtime*(hops+1)` — it omits the firmware's base (`SEND_TIMEOUT_BASE_MILLIS=500`) and per-hop processing terms (`6*airtime+250` per hop). So an ML prediction clamps as low as that floor, well under the firmware's own `500 + (6*airtime+250)*hops`. When the timer fires before an ACK could realistically return, `_handleTimeout` records a **false failure** → `recordPathResult(success:false)` decrements `routeWeight` by 0.5. A fresh path starts at weight 1.0, so **two** false timeouts drive it to ≤0 and `removePathRecord` deletes it (`path_history_service.dart:136-140`). Net: an overly tight timeout estimator actively erodes the learned route table — the opposite of what the ML system is for.
|
||||
- **Fix:** raise the direct-path min floor to include the firmware base + per-hop terms (don't let ML clamp below a physically plausible RTT); and require more evidence (e.g. a higher failure count, or a confirmed `MSG_SEND` with no ACK over multiple attempts) before deleting a route rather than after two timer fires.
|
||||
|
||||
### RETRY-4 · Low · `handleAckReceived` fallback computes a bogus `attemptIndex`
|
||||
- **Where:** `message_retry_service.dart:613-626` (fallback branch), uses `expectedHashes.indexOf(expectedHash)` as the attempt index.
|
||||
- **What:** `_expectedAckHashes[messageId]` is de-duplicated on insert (line 401), and because both firmware and client mask `attempt & 0x03`, attempts 0/4/8 (and 1/5/9, …) hash to the **same** value. So the list index is neither the real attempt number nor stable. The derived `matchedAttemptIndex` is only used to pick which attempt's `PathSelection` gets credited for the delivery — so the impact is limited to path-attribution accuracy on the fallback path (the primary `_ackHashToMessageId` mapping carries the correct snapshot). Low severity, but the value is simply wrong.
|
||||
- **Fix:** store the attempt index alongside each expected hash (or just reuse the snapshot in `_ackHashToMessageId`) instead of inferring it from a list position.
|
||||
|
||||
### RETRY-5 · Low · ML model feeds flood as `pathLength = -1`, polluting the linear coefficient
|
||||
- **Where:** `timeout_prediction_service.dart:59-98` (observation), `161-200` (training).
|
||||
- **What:** Flood deliveries are recorded with `pathLength: -1` **and** `isFlood: true`, then both are fed as features into a single global OLS regressor. A linear term that sees hop counts of `-1, 0, 1, 2, 3, …` with a discontinuity at the flood case distorts the `pathLength` coefficient; the `isFlood` dummy only partially compensates (it shifts the intercept, not the slope). Direct-path timeout predictions are therefore biased by flood observations and vice-versa.
|
||||
- **Fix:** train separate flood vs direct models, or set `pathLength = 0` (or a dedicated flood feature only) when `isFlood`, so the hop-count slope is learned from direct paths alone.
|
||||
|
||||
### RETRY-6 · Low / verify · ACK hash is computed over SMAZ-transformed text — the sent frame must use byte-identical text
|
||||
- **Where:** `message_retry_service.dart:328-351` — hash uses `prepareContactOutboundText(contact, text)` (SMAZ-encoded), but `config.sendMessage(contact, message.text, …)` is handed the **raw** text; correctness depends on `_sendMessageDirect`/`buildSendTextMsgFrame` applying the *exact same* SMAZ transform downstream.
|
||||
- **Why it matters:** if those two paths ever diverge, or if a message exceeds the firmware's `MAX_TEXT_LEN = 160` and gets truncated device-side, the receiver hashes different bytes than the client predicted → the ACK never matches → the message retries to `failed` even though it was delivered. This is a silent, hard-to-diagnose failure mode.
|
||||
- **Fix:** compute the hash from the *same* byte buffer that is actually framed and sent (single source of truth), and add a unit test that asserts hash-over-frame-text == expected ACK hash, including a >160-byte/SMAZ case.
|
||||
|
||||
## Observations (heuristics, not correctness bugs)
|
||||
- **Flood path attribution credits a path the send didn't use.** On a successful *flood* delivery, `_recordPathResult` (`meshcore_connector.dart:1309-1354`) boosts the weight of `contact.path` (the device's *current* path) even though the message was flooded, not routed over that path. It's a reasonable "the ACK probably came back this way" heuristic, but (a) it inflates weight on an unexercised route, and (b) it reads `contact.path` which the path-return packet triggered by this very ACK may have just mutated (hence the `unawaited(getContactByKey(...))` re-fetch — a race the code papers over). Worth documenting as a heuristic and considering attributing only when the device path is confirmed fresh.
|
||||
- **`calculateDefaultTimeout` (`message_retry_service.dart:713-719`)** appears to be legacy/unused by the active retry path (which always goes through `config.calculateTimeout`). If dead, remove it to avoid confusion about which timeout logic is authoritative.
|
||||
|
||||
## How the system could be improved
|
||||
1. **Single global in-flight budget tied to the firmware's table size.** Replace the per-contact gate with a global semaphore of N (≤8) outstanding ACKs, with a small reserve. This directly models the firmware constraint and eliminates RETRY-1.
|
||||
2. **Make the device timeout authoritative, ML advisory.** Use the firmware's `est_timeout` as the baseline (it has the real airtime), and let the ML model only *widen* it when history shows this contact/path is consistently slower. Never let ML clamp below a physically plausible RTT (fixes RETRY-2 + RETRY-3).
|
||||
3. **Decouple "timed out" from "route failed."** A timeout is weak evidence (could be transient congestion). Track per-path *consecutive* failures and only decay/delete a route after repeated, independent failures — or after the firmware reports a genuine send failure. Add hysteresis so one slow ACK doesn't erase a known-good route.
|
||||
4. **Separate latency models per route class** (flood vs direct, and ideally per hop-count bucket), or add the airtime estimate itself as a feature so the model learns a multiplier rather than re-deriving physics. Fixes RETRY-5 and makes predictions interpretable.
|
||||
5. **One source of truth for outbound bytes.** Frame the message once, compute the ACK hash from those exact bytes, and reject/queue anything that would exceed `MAX_TEXT_LEN` *before* sending (rather than discovering it via never-arriving ACKs). Fixes RETRY-6.
|
||||
6. **Confidence-based path selection.** `_scorePathRecord` weights reliability 0.45 / latency 0.25 / freshness 0.1 / routeWeight 0.2 with hard-coded constants. Consider surfacing these as tunables (some already are via `appSettings`) and using a Wilson lower-bound on the success ratio instead of the current `(s+1)/(n+2)` Laplace estimate, so a 1/1 path isn't ranked above a 20/22 path purely on the smoothed mean.
|
||||
7. **Bound the late-ACK grace + 15-min mapping cleanup explicitly.** `handleAckReceived` cleans `_ackHashToMessageId` older than 15 min on every ACK (`message_retry_service.dart:589-599`); combined with the 30 s post-failure grace timer and `attempt&3` hash reuse, it's worth a test that a delivered-after-failed message resolves exactly once and never double-advances the send queue.
|
||||
|
||||
|
||||
### Possible ML issues
|
||||
|
||||
1. High: training/prediction mismatch for secondsSinceLastRx.
|
||||
_lastRxTime is reset when the ACK frame arrives, then the observation is recorded, making this feature effectively zero. Prediction measures it before
|
||||
sending.
|
||||
lib/connector/meshcore_connector.dart:3867
|
||||
|
||||
3. Medium: incorrect message-size input.
|
||||
Retry prediction and training use message.text.length, not UTF-8/transformed payload bytes. Unicode and compressed messages therefore receive incorrect
|
||||
airtime inputs.
|
||||
lib/services/message_retry_service.dart:405
|
||||
lib/services/message_retry_service.dart:669
|
||||
|
||||
4. Medium: stale per-contact statistics.
|
||||
Old observations are removed from the 100-item model window, but never removed from _contactStats. The global model adapts while contact averages remain
|
||||
lifetime averages.
|
||||
lib/services/timeout_prediction_service.dart:76
|
||||
|
||||
5. Medium: model is underconstrained.
|
||||
Ordinary linear regression starts with only 10 samples and up to four features, without outlier rejection or validation. Physics clamping limits damage,
|
||||
but unstable predictions are still likely.
|
||||
|
||||
6. Low: pending observations may not persist.
|
||||
dispose() cancels the delayed save without flushing it.
|
||||
lib/services/timeout_prediction_service.dart:216
|
||||
---
|
||||
|
||||
# ACK & Message-Delivery Analysis (2026-06-12, code review vs firmware)
|
||||
|
||||
Focused pass on ACK matching and message delivery (sending + receiving), cross-checked against the MeshCore firmware at `/mnt/Gaming/meshcore/MeshCore`. Companion of the earlier "Retry & Path-Selection" section — this one is about whether messages reliably reach `delivered`/`failed` and aren't lost or double-shown.
|
||||
|
||||
**Verified correct (recording these to close earlier open questions):**
|
||||
- **ACK-hash text consistency holds.** `prepareContactOutboundText` (SMAZ / Cyr2Lat) transforms the text, and the *same* transformed bytes are used both for the wire frame (`buildSendTextMsgFrame`, `meshcore_connector.dart:~1113`) and for the expected-ack hash (`message_retry_service.dart:328-338`). The firmware hashes the raw received text bytes (SMAZ is opaque to it), so both sides agree. This resolves the RETRY-6 "verify" concern (the >160-byte truncation guard is still worth adding).
|
||||
- **Connect-time queue drain exists.** `_startPostChannelInitialQueuedMessageSync` (`meshcore_connector.dart:3835`) plus the `respCodeEndOfContacts` handler (`:3936-3941`) proactively call `syncQueuedMessages(force:true)` after the SELF_INFO→channels→contacts pipeline, so messages that arrived while disconnected are pulled on reconnect — not solely dependent on a live `PUSH_CODE_MSG_WAITING` tickle.
|
||||
- **Firmware de-dups inbound packets.** `SimpleMeshTables::hasSeen` keeps a 160-entry packet-hash seen-table (`src/helpers/SimpleMeshTables.h:34-53`), so the same on-air packet arriving via multiple flood paths is dropped, not delivered twice.
|
||||
- **Channel sends do reach `sent`.** `_handleOk` promotes the pending channel message on the firmware's generic OK (`meshcore_connector.dart:5370`), and the echo-heard path promotes it again (`:5990`). (So the "stuck pending on success" worry is unfounded — but see DELIVERY-1 for the error path.)
|
||||
|
||||
## Open findings
|
||||
|
||||
### DELIVERY-1 · Medium · A rejected (or lost-response) channel send is stuck "pending" forever
|
||||
- **Where:** `_handleErrorFrame` (`meshcore_connector.dart:4007-4024`); `ChannelMessageStatus` has only `{pending, sent, failed}` and nothing ever sets `failed` for a channel message.
|
||||
- **What:** When the firmware answers a `cmdSendChannelTxtMsg` with an error frame (e.g. `ERR_CODE_NOT_FOUND` for an unknown/unconfigured channel — firmware `MyMesh.cpp:1131-1135` writes an err frame on failure), the handler removes the message from `_pendingChannelSentQueue` (line 4023) **but never marks the message `failed`**. The same happens if the OK/ERR response is simply lost. Result: the bubble shows "pending/sending" indefinitely with no error, and there is no retry or timeout to resolve it.
|
||||
- **Contrast:** direct messages have a full timeout→retry→`failed` path; channel messages have *no* failure path at all.
|
||||
- **Fix:** in `_handleErrorFrame`, set the matching channel message to `failed` (mirror `_markPendingChannelMessageSentById`). Optionally add a short watchdog so a channel send with no OK/ERR within N seconds flips `pending`→`failed`.
|
||||
|
||||
### DELIVERY-2 · Medium · Firmware offline queue (16) silently drops *contact* messages on overflow
|
||||
- **Where:** firmware `examples/companion_radio/MyMesh.cpp:219-255` (`addToOfflineQueue`), `MyMesh.h:240` (`OFFLINE_QUEUE_SIZE 16`).
|
||||
- **What:** Inbound messages are buffered in a 16-slot queue and tickled to the app via `PUSH_CODE_MSG_WAITING`. On overflow the firmware evicts the oldest **channel** message to make room; if all 16 queued entries are **contact/direct** messages, the new message is **silently dropped** — no error, no notification to the app.
|
||||
- **Consequence:** a burst of direct messages while the app is backgrounded/slow/disconnected can lose messages with zero indication. The client drains in a loop on tickle and on connect (good), but it cannot recover a message the firmware already discarded.
|
||||
- **Fix (client side):** drain as fast as possible — the current one-at-a-time request/response loop (`_requestNextQueuedMessage`) round-trips per message; consider keeping the pump saturated. There's no app-side way to detect the drop, so also worth raising upstream (a queue-overflow counter in firmware stats would let the app warn the user).
|
||||
|
||||
### DELIVERY-3 · Low/Medium · Inbound contact-message de-dup can drop a *legitimate* message
|
||||
- **Where:** `_handleIncomingMessage` de-dup (`meshcore_connector.dart:~4759-4772`): skips an incoming message if a message with the same `(timestampSeconds, text)` exists in the last 10 messages from that sender.
|
||||
- **What:** Timestamps are second-resolution, so two genuinely distinct messages with identical text within the same second (e.g. sending "ok" twice) collide and the second is silently dropped. The firmware already de-dups true on-air duplicates at the packet-hash layer (see "verified correct"), so this app-layer heuristic is largely redundant *and* introduces a real (if uncommon) data-loss path; it also only looks back 10 messages, so a re-delivered queued message after a longer gap can slip through as a "new" duplicate.
|
||||
- **Fix:** lean on the firmware's packet identity instead of `(timestamp,text)`, or narrow the heuristic (e.g. only treat as duplicate within a very short window AND when flagged as a flood repeat), so identical-text messages aren't lost.
|
||||
|
||||
### DELIVERY-4 · Low · ACK match is 4-byte-hash-only, no sender identity, against a *global* 8-slot table
|
||||
- **Where:** firmware `processAck` (`MyMesh.cpp:411-427`) — `memcmp(data, &expected_ack_table[i].ack, 4)` over 8 slots, clears the slot to 0 on first match.
|
||||
- **What:** matching uses only the first 4 hash bytes with no check that the ACK came from the intended recipient, against the same global 8-entry circular table called out in RETRY-1. So (a) >8 concurrent in-flight sends evict a slot → that message never gets `PUSH_CODE_SEND_CONFIRMED` → client retries/false-fails; (b) a ~1/2^32-per-concurrent-send chance of a cross-message false confirm. Duplicate ACKs are handled safely (slot cleared after first match; the random 6th ACK byte doesn't affect the 4-byte compare). Low severity on its own; the real lever is RETRY-1's recommended **global** in-flight cap (≤8).
|
||||
|
||||
### DELIVERY-5 · Info · Round-trip time is measured from queueing, not transmission
|
||||
- **Where:** firmware `trip_time = getMillis() - expected_ack_table[i].msg_sent` (`MyMesh.cpp:417`), where `msg_sent` is stamped when the message is enqueued (`:1100`).
|
||||
- **What:** the `tripTimeMs` the app receives (and feeds the ML timeout model) includes the firmware's TX-queue wait when the radio is busy, so observations are inflated under load. Compounds the ML-input issues already logged (RETRY-5 / the ML notes above). Not a delivery bug, but it skews timeout prediction.
|
||||
|
||||
### DELIVERY-6 · Low / verify · Command/CLI sends use `expected_ack = 0` — must not ride the ACK retry path
|
||||
- **Where:** firmware sets `expected_ack = 0` for `TXT_TYPE_CLI_DATA` and the recipient sends no ACK (`MyMesh.cpp:1088-1110`, `BaseChatMesh.cpp:244-252`); `RESP_CODE_SENT` then carries a zero hash.
|
||||
- **What:** if any client path routes a CLI/repeater command through `MessageRetryService` (which computes a *non-zero* expected hash and waits for `PUSH_CODE_SEND_CONFIRMED`), it would never match → spurious retries and a false "failed". Need to confirm the repeater-CLI/command path is separate from the text-message ACK machinery (the connector does call `_recordPathResult` for "repeater command results" — worth tracing that it isn't also arming an ACK wait).
|
||||
- **Fix/verify:** ensure command sends bypass the expected-ack/retry tracking entirely, or special-case `expected_ack == 0` in `updateMessageFromSent` as "no ACK expected → mark sent, don't wait".
|
||||
|
||||
---
|
||||
|
||||
# Live Log Analysis — "JC Room Server" session (2026-06-12)
|
||||
|
||||
Real device log (BLE/USB, auto route rotation ON) sending to a multi-hop room server. This is a near-perfect live reproduction of RETRY-2, RETRY-3, and RETRY-5. The ACK *matching* is correct (every `RESP_CODE_SENT` matches its message); what's broken is **timeout estimation and path rotation** — the app declares failures and retries messages that are actually still in flight.
|
||||
|
||||
### Evidence by symptom
|
||||
|
||||
1. **Flood timeout = 151 seconds (RETRY-5, live).**
|
||||
`raw prediction=100638ms for pathLength=-1 → ML timeout 150959ms`. Feeding `pathLength=-1` into the linear model makes the flood case extrapolate off a cliff; the `.clamp(physicsMin, physicsMax)` didn't help because the flood `physicsMax` (`500 + 16·airtime`) was also huge. A message that falls back to flood effectively hangs for ~2.5 minutes.
|
||||
- Also seen: `raw prediction=-3467ms for pathLength=1, messageBytes=172` — a **negative** predicted time. Discarded by the `<=0` guard, but proves the OLS model is unstable/underconstrained (matches the ML notes).
|
||||
|
||||
2. **Premature timeouts on a working route (RETRY-3, live).**
|
||||
`"Its going well"` was **delivered in 16037ms**, but attempt 0/1 used timeouts of **14420ms / 10240ms** — shorter than the real round-trip — so they fired `Timeout → retrying` before the ACK could return. The `g:` message `delivered ... in 9418ms` arrived *after* a retry had already been scheduled. Messages that are genuinely in flight are being declared failed.
|
||||
|
||||
3. **Device `est_timeout` ignored (RETRY-2, live).**
|
||||
Every send gets a clean `RESP_CODE_SENT` (which carries the firmware's own `est_timeout`), but the app overrides it with the runaway ML value. The device's estimate would have been far saner than both the 10s (too short) and 151s (too long) ML outputs.
|
||||
|
||||
4. **Path rotation never converges.**
|
||||
Single message, per-retry path: `pathLength 3 → 2 → 1 → 2 → -1(flood)`; timeout target swings `25s → 19s → 13s → 19s → 151s`. Routes that just delivered (`g:` over 2 hops) are abandoned on the next message. On a 2-5 hop room server on a congested channel, the thrash never settles.
|
||||
|
||||
5. **Retries congest the channel.**
|
||||
`Radio quiet for 3037ms`, `Post-inbound backoff: waiting 14808ms` — channel is busy. Backoffs (`1/2/4/8s`) are far shorter than the 19-34s timeouts, so retries stack onto a multi-hop path and reduce delivery odds.
|
||||
|
||||
6. **`attempt & 3` hash reuse visible.** `"I have some really c..."` attempt 0 and attempt 4 both expect `b8bfa902`; `"JC you around?"` reuses `b2074391`. Attempts 0/4 are indistinguishable on the wire (RETRY-4 / masking).
|
||||
|
||||
7. **Room login runs a separate, untracked ACK path (relates to DELIVERY-6).** `No pending message found for ACK hash: b0023dab` ×5 during `[RoomLogin]` — login sends with an expected ACK that `MessageRetryService` never registered, so every login confirm logs as unmatched. Login has its own 26s timeout/retry and eventually succeeds.
|
||||
|
||||
### Highest-impact fix (from this log)
|
||||
Fix the timeout side: **stop feeding flood as `pathLength=-1`, hard-cap the ML output (≈30-45s), and fall back to the device's `est_timeout` instead of the runaway ML value.** That alone kills the 151s hang and the premature retries. Contained to `timeout_prediction_service.dart` + `calculateTimeout` in `meshcore_connector.dart`.
|
||||
|
||||
---
|
||||
|
||||
# Design Proposal — Replace the timeout regressor; split timeout from loss (2026-06-12)
|
||||
|
||||
Outcome of analyzing the JC Room Server log: the OLS `LinearRegressor` in `timeout_prediction_service.dart` is the wrong tool, and it's conflating two separate questions. This sketches the replacement before any code changes.
|
||||
|
||||
## The core insight (why this isn't TCP, and what that implies)
|
||||
|
||||
This is slow, **lossy** multi-hop radio: a message can be lost on a forward hop (never reaches the recipient) or delivered-but-the-ACK-return-is-lost. Two consequences drive the whole design:
|
||||
|
||||
1. **On a lossy link, a longer timeout recovers nothing — only a retry does.** The timeout is your *time-to-retry*. Loss is the common outcome to a distant (5-hop) node, so inflating the timeout (e.g. the observed 151 s flood value) just maximizes idle time before the one action that helps. The right timeout is **the tightest value that still covers a realistically *successful* round-trip** (~P90–95 of success RTT), then give up and retry.
|
||||
2. **The latency model is already blind to loss.** `recordObservation` is only called from `handleAckReceived` — i.e. only on success. Lost messages produce no sample. So the regressor estimates "time of a *successful* round-trip" (same data an EWMA would use), just with extrapolation/negative/explosion failure modes on top. Modeling loss for real would require censored-data/survival analysis, which is far heavier than needed.
|
||||
|
||||
**Conclusion:** stop using one fragile latency regressor to answer both "how long to wait" and "is this route any good." Split them.
|
||||
|
||||
## Part A — Timeout: physics-anchored, capped, adaptive RTT (replaces the OLS model)
|
||||
|
||||
Drop `ml_algo` / `ml_dataframe`. Keep the existing `predictTimeout` / `recordObservation` interface so `calculateTimeout` and the connector wiring are untouched; swap the internals.
|
||||
|
||||
Per **route class** = (hop count, flood-vs-direct); shared across contacts since data is sparse.
|
||||
|
||||
```
|
||||
// baseline from physics (deterministic, generalizes to unseen hop/byte combos
|
||||
// from sample #1). Use the LoRa airtime formula already in the connector, or the
|
||||
// device's est_timeout from RESP_CODE_SENT (RETRY-2).
|
||||
base = physicsEstimate(hops, bytes, sf, bw, cr)
|
||||
|
||||
// learn only a BOUNDED multiplicative overhead from real successes (congestion,
|
||||
// per-hop processing). EWMA, clamped so it can never explode or go negative.
|
||||
on success(sample, hops, bytes):
|
||||
ratio = sample / physicsEstimate(hops, bytes, ...)
|
||||
f[class] = (1-α)·f[class] + α·clamp(ratio, 0.5, 4.0) // α≈0.125
|
||||
v[class] = (1-β)·v[class] + β·|f[class] - ratio| // β≈0.25 (deviation)
|
||||
|
||||
predictTimeout(hops, bytes):
|
||||
est = base · f[class]
|
||||
return clamp(est + 4·v[class]·base, physicsMin, HARD_MAX) // HARD_MAX ≈ 30–45 s
|
||||
```
|
||||
|
||||
Properties this fixes, by construction:
|
||||
- **No 151 s / no negatives** — output is `physics × bounded factor + bounded variance`, hard-capped.
|
||||
- **No flood discontinuity** — flood is its own class with its own factor, never `pathLength = -1` on a shared axis (kills RETRY-5).
|
||||
- **No cold start** — seed `f=1`, `v=0`; the physics/device baseline is sensible from message #1; observations only refine the factor (removes the "need 10 samples" gate).
|
||||
- **Variance is the timeout's whole point** — `est + k·deviation` is a calibrated high-percentile wait, so genuinely-in-flight messages aren't cut off early (kills RETRY-3), while staying tight enough that lost messages retry promptly.
|
||||
- **Drops two dependencies**, ~15 lines, debuggable.
|
||||
|
||||
Also remove the dead/broken `secSinceRx` feature (recorded ≈0; see ML notes) and feed transformed/UTF-8 payload bytes, not `text.length`.
|
||||
|
||||
## Part B — Loss: route reliability drives retry / reroute / flood (data already exists)
|
||||
|
||||
The "is this path even working" question belongs to `PathHistoryService`, which already tracks `successCount` / `failureCount` / `routeWeight` per path. Treat that as a Bernoulli delivery-probability estimate (Wilson or Beta lower bound on success rate) and use it for the *decision*, not the timeout:
|
||||
|
||||
- **High-confidence working route** → keep using it; on a single timeout, retry the *same* path once (the ACK return may just have been lost) before changing anything.
|
||||
- **Degrading route** (success rate dropping) → switch to the next-best ranked path rather than hammering a dying one.
|
||||
- **No confident route / repeated failures** → flood to rediscover, then pin the rediscovered path (don't immediately rotate off it — the log shows good routes being abandoned the next message).
|
||||
|
||||
This replaces the current "rotate the path on every retry" churn (which made the timeout target swing 25 s→19 s→13 s→19 s→151 s and never converged) with: **pin a working route; only re-route/flood when reliability says the route is actually bad.**
|
||||
|
||||
## Sequencing
|
||||
1. Part A first (self-contained, biggest visible win — kills the 151 s hang and premature retries; removes ml_algo). Same public interface, so low blast radius.
|
||||
2. Then Part B (path-decision policy), which also subsumes much of the separate "location-change brittleness" work — a route that stops delivering after you move is just a route whose reliability dropped.
|
||||
@@ -328,6 +328,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
String? get deviceId => _deviceId;
|
||||
String get deviceIdLabel => _deviceId ?? 'Unknown';
|
||||
|
||||
/// Stable per-radio key for transport-agnostic per-device settings such as
|
||||
/// battery chemistry. On BLE this is the existing remoteId (so previously
|
||||
/// saved settings are preserved); on USB/TCP — where there is no BLE
|
||||
/// remoteId — it falls back to the node's public key, which identifies the
|
||||
/// same physical radio across transports. Null until a device identity is
|
||||
/// known.
|
||||
String? get batteryDeviceKey {
|
||||
if (_deviceId != null) return _deviceId;
|
||||
if (_selfPublicKey != null && _selfPublicKey!.isNotEmpty) {
|
||||
return selfPublicKeyHex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MeshCoreTransportType get activeTransport => _activeTransport;
|
||||
String? get activeUsbPort => _usbManager.activePortKey;
|
||||
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
|
||||
@@ -493,7 +507,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String _batteryChemistryForDevice() {
|
||||
final deviceId = _device?.remoteId.toString();
|
||||
final deviceId = batteryDeviceKey;
|
||||
if (deviceId == null || _appSettingsService == null) return 'nmc';
|
||||
return _appSettingsService!.batteryChemistryForDevice(deviceId);
|
||||
}
|
||||
@@ -1607,6 +1621,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await stopScan();
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
// The read pump can fail the instant the port opens (e.g. a device that
|
||||
// re-enumerates on open). That error is emitted on a broadcast stream
|
||||
// before the listener below attaches, so it would otherwise be lost and
|
||||
// the connect would stall until the SELF_INFO timeout. Check transport
|
||||
// liveness directly and abort fast with the real cause.
|
||||
if (!_usbManager.isConnected) {
|
||||
final cause = _usbManager.lastError;
|
||||
throw StateError(
|
||||
'USB device disconnected during connect'
|
||||
'${cause == null ? '' : ': $cause'}',
|
||||
);
|
||||
}
|
||||
|
||||
_usbFrameSubscription = _usbManager.frameStream.listen(
|
||||
_handleFrame,
|
||||
onError: (error, stackTrace) {
|
||||
@@ -5737,7 +5765,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
) {
|
||||
if (!isRoomServer) return null;
|
||||
if (!msg.isOutgoing) {
|
||||
final senderContact = _contacts.cast<Contact?>().firstWhere(
|
||||
// Saved contacts first, then discovery-only nodes, so reaction matching
|
||||
// resolves the author's name even when they haven't been saved.
|
||||
final senderContact = allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
|
||||
|
||||
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
|
||||
String? get activePortKey => _activePortKey;
|
||||
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Object? get lastError => _service.lastError;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"scanner_chromeRequired": "Chrome Browser Required",
|
||||
"scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.",
|
||||
"scanner_enableBluetooth": "Enable Bluetooth",
|
||||
"scanner_bluetoothWebUnsupported": "Bluetooth isn't available in the browser. Connect over USB instead.",
|
||||
"device_quickSwitch": "Quick switch",
|
||||
"device_meshcore": "MeshCore",
|
||||
"settings_title": "Settings",
|
||||
|
||||
@@ -694,6 +694,12 @@ abstract class AppLocalizations {
|
||||
/// **'Enable Bluetooth'**
|
||||
String get scanner_enableBluetooth;
|
||||
|
||||
/// No description provided for @scanner_bluetoothWebUnsupported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bluetooth isn\'t available in the browser. Connect over USB instead.'**
|
||||
String get scanner_bluetoothWebUnsupported;
|
||||
|
||||
/// No description provided for @device_quickSwitch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsBg extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Активирайте Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Бързо превключване';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Bluetooth aktivieren';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Schnelles Umschalten';
|
||||
|
||||
|
||||
@@ -315,6 +315,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Enable Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Quick switch';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Habilitar Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Cambiar rápidamente';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Activer le Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Basculement rapide';
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsHu extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Engedje be a Bluetooth funkciót';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Gyors váltás';
|
||||
|
||||
|
||||
@@ -321,6 +321,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Abilita il Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Passa velocemente';
|
||||
|
||||
|
||||
@@ -308,6 +308,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Bluetoothを有効にする';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '素早い切り替え';
|
||||
|
||||
|
||||
@@ -307,6 +307,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => '블루투스 활성화';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '빠른 전환';
|
||||
|
||||
|
||||
@@ -317,6 +317,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Activeer Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Snelle overschakeling';
|
||||
|
||||
|
||||
@@ -322,6 +322,10 @@ class AppLocalizationsPl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Włącz Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Szybka zmiana';
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Ative o Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Mudar rapidamente';
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Включите Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Быстрое переключение';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsSk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Povolte Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Rýchle prepínač';
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Omogočite Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Hitro preklop';
|
||||
|
||||
|
||||
@@ -316,6 +316,10 @@ class AppLocalizationsSv extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Aktivera Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Snabb växling';
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => 'Швидке перемикання';
|
||||
|
||||
|
||||
@@ -305,6 +305,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get scanner_enableBluetooth => '启用蓝牙';
|
||||
|
||||
@override
|
||||
String get scanner_bluetoothWebUnsupported =>
|
||||
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
|
||||
|
||||
@override
|
||||
String get device_quickSwitch => '快速切换';
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class AppSettings {
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.mapShowGuessedLocations = true,
|
||||
this.enableMessageTracing = false,
|
||||
this.enableMessageTracing = true,
|
||||
this.mapCacheBounds,
|
||||
this.mapCacheMinZoom = 10,
|
||||
this.mapCacheMaxZoom = 15,
|
||||
@@ -149,7 +149,7 @@ class AppSettings {
|
||||
this.notifyOnNewMessage = true,
|
||||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.autoRouteRotationEnabled = true,
|
||||
this.maxRouteWeight = 5.0,
|
||||
this.initialRouteWeight = 3.0,
|
||||
this.routeWeightSuccessIncrement = 0.5,
|
||||
@@ -264,7 +264,7 @@ class AppSettings {
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapShowGuessedLocations:
|
||||
json['map_show_guessed_locations'] as bool? ?? true,
|
||||
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false,
|
||||
enableMessageTracing: json['enable_message_tracing'] as bool? ?? true,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
@@ -276,7 +276,7 @@ class AppSettings {
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
json['auto_route_rotation_enabled'] as bool? ?? true,
|
||||
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
|
||||
initialRouteWeight:
|
||||
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
|
||||
|
||||
@@ -607,7 +607,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
AppSettingsService settingsService,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final deviceId = connector.batteryDeviceKey;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
|
||||
@@ -381,12 +381,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
final messageIndex = index;
|
||||
Contact contact = _resolveContact(connector);
|
||||
final bool isRoom = contact.type == advTypeRoom;
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (contact.type == advTypeRoom) {
|
||||
Contact? roomAuthor;
|
||||
if (isRoom) {
|
||||
// Room-server messages carry the original author's 4-byte prefix
|
||||
// separately from message.text; use it only for resolving the name.
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
roomAuthor = _resolveContactFrom4Bytes(
|
||||
connector,
|
||||
message.fourByteRoomContactKey.isEmpty
|
||||
? Uint8List.fromList([0, 0, 0, 0])
|
||||
@@ -396,6 +398,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
// Only adopt the author identity when we actually know them; never
|
||||
// fall back to the room server's own name as the sender.
|
||||
if (roomAuthor != null) contact = roomAuthor;
|
||||
}
|
||||
|
||||
return Builder(
|
||||
@@ -403,11 +408,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final textScale = context.select<ChatTextScaleService, double>(
|
||||
(service) => service.scale,
|
||||
);
|
||||
final resolvedContact = _resolveContact(connector);
|
||||
final bubble = _MessageBubble(
|
||||
message: message,
|
||||
senderName: resolvedContact.type == advTypeRoom
|
||||
? "${contact.name} [$fourByteHex]"
|
||||
senderName: isRoom
|
||||
? (roomAuthor != null
|
||||
? "${roomAuthor.name} [$fourByteHex]"
|
||||
: "[$fourByteHex]")
|
||||
: contact.name,
|
||||
sourceId: widget.contact.publicKeyHex,
|
||||
textScale: textScale,
|
||||
@@ -755,13 +761,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
Contact _resolveContactFrom4Bytes(
|
||||
Contact? _resolveContactFrom4Bytes(
|
||||
MeshCoreConnector connector,
|
||||
Uint8List key4Bytes,
|
||||
) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => widget.contact,
|
||||
// Match against saved contacts first, then nodes only seen via discovery —
|
||||
// a room poster you haven't saved may still be in the discovered list.
|
||||
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
|
||||
(c) =>
|
||||
c != null &&
|
||||
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1049,7 +1059,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (message.isOutgoing) {
|
||||
senderName = connector.selfName ?? context.l10n.chat_me;
|
||||
} else if (_resolveContact(connector).type == advTypeRoom) {
|
||||
senderName = "${contact.name} [$fourByteHex]";
|
||||
// An unresolved author leaves `contact` as the room server itself; show
|
||||
// only the prefix rather than mislabeling the post with the room's name.
|
||||
senderName = contact.type == advTypeRoom
|
||||
? "[$fourByteHex]"
|
||||
: "${contact.name} [$fourByteHex]";
|
||||
} else {
|
||||
senderName = _resolveContact(connector).name;
|
||||
}
|
||||
|
||||
+358
-40
@@ -1,4 +1,3 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -94,6 +93,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
String _searchQuery = '';
|
||||
List<_GuessedLocation> _cachedGuessedLocations = [];
|
||||
String _guessedLocationsCacheKey = '';
|
||||
int? _sharedMarkersCacheSignature;
|
||||
Locale? _sharedMarkersCacheLocale;
|
||||
List<_SharedMarker> _cachedSharedMarkers = const [];
|
||||
_NodeMarkersCacheKey? _nodeMarkersCacheKey;
|
||||
List<Marker> _cachedNodeMarkers = const [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -283,11 +287,23 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final connectorSnapshot = context
|
||||
.select<MeshCoreConnector, _MapConnectorSnapshot>(
|
||||
_MapConnectorSnapshot.fromConnector,
|
||||
);
|
||||
final connector = connectorSnapshot.connector;
|
||||
final settings = context.select<AppSettingsService, AppSettings>(
|
||||
(service) => service.settings,
|
||||
);
|
||||
final pathHistoryVersion = context.select<PathHistoryService, int>(
|
||||
(service) => service.version,
|
||||
);
|
||||
final settingsService = context.read<AppSettingsService>();
|
||||
final pathHistory = context.read<PathHistoryService>();
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||
final settings = settingsService.settings;
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final contacts = settings.mapShowDiscoveryContacts
|
||||
@@ -296,7 +312,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
final highlightPosition = widget.highlightPosition;
|
||||
final sharedMarkers = settings.mapShowMarkers
|
||||
? _collectSharedMarkers(connector)
|
||||
? _collectSharedMarkers(
|
||||
connector,
|
||||
connectorSnapshot.markerSignature,
|
||||
)
|
||||
.where(
|
||||
(marker) =>
|
||||
!_hiddenMarkerIds.contains(marker.id) &&
|
||||
@@ -347,9 +366,17 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Guessed markers represent the same node types as known-location
|
||||
// markers, so apply the node-type filters before estimating positions.
|
||||
final guessCandidates = _filterContactsBySettings(
|
||||
filteredByKeyPrefix,
|
||||
settings,
|
||||
noLocations: true,
|
||||
);
|
||||
|
||||
// Compute guessed locations with caching
|
||||
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
||||
final filteredKeys = filteredByKeyPrefix
|
||||
final filteredKeys = guessCandidates
|
||||
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
||||
.join(',');
|
||||
final anchorKeys = allContactsWithLocation
|
||||
@@ -359,12 +386,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
)
|
||||
.join(',');
|
||||
final cacheKey =
|
||||
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
'$filteredKeys|$anchorKeys|$pathHistoryVersion:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
if (cacheKey != _guessedLocationsCacheKey) {
|
||||
_guessedLocationsCacheKey = cacheKey;
|
||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||
? _computeGuessedLocations(
|
||||
filteredByKeyPrefix,
|
||||
guessCandidates,
|
||||
allContactsWithLocation,
|
||||
pathHistory,
|
||||
maxRangeKm,
|
||||
@@ -759,9 +786,21 @@ class _MapScreenState extends State<MapScreen> {
|
||||
guessedLocations,
|
||||
showLabels: _showNodeLabels,
|
||||
),
|
||||
..._buildNodeMarkers(
|
||||
..._buildNodeMarkersCached(
|
||||
visibleContacts,
|
||||
settings,
|
||||
connectorSnapshot.contactsSignature,
|
||||
connectorSnapshot.batterySignature,
|
||||
_freshness,
|
||||
settings.mapTimeFilterHours,
|
||||
settings.mapKeyPrefixEnabled,
|
||||
settings.mapKeyPrefix,
|
||||
settings.mapShowDiscoveryContacts,
|
||||
Object.hashAllUnordered(
|
||||
settings.batteryChemistryByRepeaterId.entries.map(
|
||||
(entry) => Object.hash(entry.key, entry.value),
|
||||
),
|
||||
),
|
||||
showLabels: _showNodeLabels,
|
||||
selectedContact: selectedContact,
|
||||
),
|
||||
@@ -873,6 +912,59 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildNodeMarkersCached(
|
||||
List<Contact> contacts,
|
||||
AppSettings settings,
|
||||
int contactsSignature,
|
||||
int batterySignature,
|
||||
_Freshness freshness,
|
||||
double timeFilterHours,
|
||||
bool keyPrefixEnabled,
|
||||
String keyPrefix,
|
||||
bool showDiscoveryContacts,
|
||||
int batteryChemistrySignature, {
|
||||
required bool showLabels,
|
||||
Contact? selectedContact,
|
||||
}) {
|
||||
final visibleContactsSignature = Object.hashAll(
|
||||
contacts.map(
|
||||
(contact) =>
|
||||
Object.hash(_mapContactSignature(contact), _ageOf(contact)),
|
||||
),
|
||||
);
|
||||
final key = _NodeMarkersCacheKey(
|
||||
contactsSignature: contactsSignature,
|
||||
visibleContactsSignature: visibleContactsSignature,
|
||||
batterySignature: batterySignature,
|
||||
freshness: freshness,
|
||||
timeFilterHours: timeFilterHours,
|
||||
keyPrefixEnabled: keyPrefixEnabled,
|
||||
keyPrefix: keyPrefix,
|
||||
showDiscoveryContacts: showDiscoveryContacts,
|
||||
batteryChemistrySignature: batteryChemistrySignature,
|
||||
showLabels: showLabels,
|
||||
selectedKey: selectedContact?.publicKeyHex,
|
||||
zoom: _zoom,
|
||||
overlapsMode: settings.mapShowOverlaps,
|
||||
showRepeaters: settings.mapShowRepeaters,
|
||||
showChatNodes: settings.mapShowChatNodes,
|
||||
showOtherNodes: settings.mapShowOtherNodes,
|
||||
isBuildingPathTrace: _isBuildingPathTrace,
|
||||
);
|
||||
if (key != _nodeMarkersCacheKey) {
|
||||
_nodeMarkersCacheKey = key;
|
||||
_cachedNodeMarkers = List.unmodifiable(
|
||||
_buildNodeMarkers(
|
||||
contacts,
|
||||
settings,
|
||||
showLabels: showLabels,
|
||||
selectedContact: selectedContact,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _cachedNodeMarkers;
|
||||
}
|
||||
|
||||
List<_GuessedLocation> _computeGuessedLocations(
|
||||
List<Contact> allContacts,
|
||||
List<Contact> withLocation,
|
||||
@@ -1146,17 +1238,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}) {
|
||||
List<Contact> filtered = [];
|
||||
bool addContact = false;
|
||||
|
||||
for (final contact in contacts) {
|
||||
addContact = false;
|
||||
if (!contact.hasLocation && !noLocations) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply node type filters
|
||||
// Apply node type filters. The overlaps toggle is purely a visual
|
||||
// highlight (applied in _buildNodeMarkers) and no longer affects which
|
||||
// nodes are shown.
|
||||
if (contact.type == advTypeRepeater &&
|
||||
(settings.mapShowRepeaters ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
(settings.mapShowRepeaters || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
if (contact.type == advTypeChat &&
|
||||
@@ -1165,9 +1258,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
if (contact.type != advTypeChat &&
|
||||
contact.type != advTypeRepeater &&
|
||||
(settings.mapShowOtherNodes ||
|
||||
_isBuildingPathTrace ||
|
||||
settings.mapShowOverlaps)) {
|
||||
(settings.mapShowOtherNodes || _isBuildingPathTrace)) {
|
||||
addContact = true;
|
||||
}
|
||||
|
||||
@@ -1175,25 +1266,6 @@ class _MapScreenState extends State<MapScreen> {
|
||||
addContact = false;
|
||||
}
|
||||
|
||||
if (settings.mapShowOverlaps) {
|
||||
final hasOverlap = contacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKeyHex != contact.publicKeyHex &&
|
||||
c.publicKey.first == contact.publicKey.first &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
|
||||
(contact.type == advTypeRepeater ||
|
||||
contact.type == advTypeRoom),
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (hasOverlap == null &&
|
||||
settings.mapShowOverlaps &&
|
||||
!_isBuildingPathTrace) {
|
||||
addContact = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (addContact) {
|
||||
filtered.add(contact);
|
||||
}
|
||||
@@ -1212,13 +1284,34 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final selectedKey = selectedContact?.publicKeyHex;
|
||||
final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList();
|
||||
|
||||
// Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms
|
||||
// whose first key byte collides with another repeater/room on the map.
|
||||
final overlapPrefixes = <int>{};
|
||||
if (overlapsMode) {
|
||||
final counts = <int, int>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.type == advTypeRepeater || contact.type == advTypeRoom) {
|
||||
final prefix = contact.publicKey.first;
|
||||
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
counts.forEach((prefix, count) {
|
||||
if (count > 1) overlapPrefixes.add(prefix);
|
||||
});
|
||||
}
|
||||
bool isOverlap(Contact contact) =>
|
||||
overlapsMode &&
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
overlapPrefixes.contains(contact.publicKey.first);
|
||||
|
||||
void addNode(Contact contact, {bool dot = false}) {
|
||||
markers.add(_nodeMarker(contact, overlapsMode: overlapsMode, dot: dot));
|
||||
final overlap = isOverlap(contact);
|
||||
markers.add(_nodeMarker(contact, overlapsMode: overlap, dot: dot));
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
label: overlapsMode
|
||||
label: overlap
|
||||
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
||||
: contact.name,
|
||||
),
|
||||
@@ -1255,7 +1348,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
markers.add(
|
||||
_nodeMarker(
|
||||
selectedContact,
|
||||
overlapsMode: overlapsMode,
|
||||
overlapsMode: isOverlap(selectedContact),
|
||||
selected: true,
|
||||
),
|
||||
);
|
||||
@@ -2371,7 +2464,16 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
|
||||
List<_SharedMarker> _collectSharedMarkers(
|
||||
MeshCoreConnector connector,
|
||||
int markerSignature,
|
||||
) {
|
||||
final locale = Localizations.localeOf(context);
|
||||
if (_sharedMarkersCacheSignature == markerSignature &&
|
||||
_sharedMarkersCacheLocale == locale) {
|
||||
return _cachedSharedMarkers;
|
||||
}
|
||||
|
||||
// Build a _SharedMarker per message (history empty), grouped by dedupe key.
|
||||
// Afterwards pick the latest per key and fill its history from older ones.
|
||||
final updatesByKey = <String, List<_SharedMarker>>{};
|
||||
@@ -2463,7 +2565,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
});
|
||||
|
||||
markers.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return markers;
|
||||
_sharedMarkersCacheSignature = markerSignature;
|
||||
_sharedMarkersCacheLocale = locale;
|
||||
_cachedSharedMarkers = List.unmodifiable(markers);
|
||||
return _cachedSharedMarkers;
|
||||
}
|
||||
|
||||
Marker _buildSharedMarker(_SharedMarker marker) {
|
||||
@@ -3564,6 +3669,219 @@ enum _NodeAge { online, recent, stale }
|
||||
|
||||
enum _Freshness { all, online, recent, stale }
|
||||
|
||||
int _bytesSignature(Iterable<int>? bytes) {
|
||||
if (bytes == null) return 0;
|
||||
return Object.hashAll(bytes);
|
||||
}
|
||||
|
||||
int _mapContactSignature(Contact contact) {
|
||||
return Object.hash(
|
||||
contact.publicKeyHex,
|
||||
contact.name,
|
||||
contact.type,
|
||||
contact.flags,
|
||||
contact.pathLength,
|
||||
_bytesSignature(contact.path),
|
||||
contact.pathOverride,
|
||||
_bytesSignature(contact.pathOverrideBytes),
|
||||
contact.latitude,
|
||||
contact.longitude,
|
||||
contact.lastSeen.millisecondsSinceEpoch,
|
||||
contact.lastMessageAt.millisecondsSinceEpoch,
|
||||
contact.isActive,
|
||||
contact.wasPulled,
|
||||
);
|
||||
}
|
||||
|
||||
class _MapConnectorSnapshot {
|
||||
final MeshCoreConnector connector;
|
||||
final int contactsSignature;
|
||||
final int markerSignature;
|
||||
final int batterySignature;
|
||||
final int uiSignature;
|
||||
|
||||
const _MapConnectorSnapshot({
|
||||
required this.connector,
|
||||
required this.contactsSignature,
|
||||
required this.markerSignature,
|
||||
required this.batterySignature,
|
||||
required this.uiSignature,
|
||||
});
|
||||
|
||||
factory _MapConnectorSnapshot.fromConnector(MeshCoreConnector connector) {
|
||||
final allContacts = connector.allContacts;
|
||||
final contactsSignature = Object.hashAll(
|
||||
allContacts.map(_mapContactSignature),
|
||||
);
|
||||
final batterySignature = Object.hashAll(
|
||||
allContacts
|
||||
.where((contact) => contact.type == advTypeRepeater)
|
||||
.map(
|
||||
(contact) => Object.hash(
|
||||
contact.publicKeyHex,
|
||||
connector.getRepeaterBatteryMillivolts(contact.publicKeyHex),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final markerParts = <Object?>[connector.selfName];
|
||||
for (final contact in connector.contacts) {
|
||||
markerParts.add(contact.publicKeyHex);
|
||||
markerParts.add(contact.name);
|
||||
for (final message in connector.getMessages(contact)) {
|
||||
if (!message.text.trimLeft().startsWith('m:')) continue;
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.timestamp.millisecondsSinceEpoch,
|
||||
message.isOutgoing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final channel in connector.channels) {
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
channel.index,
|
||||
channel.name,
|
||||
channel.isPublicChannel,
|
||||
channel.isEmpty,
|
||||
),
|
||||
);
|
||||
for (final message in connector.getChannelMessages(channel)) {
|
||||
if (!message.text.trimLeft().startsWith('m:')) continue;
|
||||
markerParts.add(
|
||||
Object.hash(
|
||||
message.messageId,
|
||||
message.text,
|
||||
message.senderName,
|
||||
message.timestamp.millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return _MapConnectorSnapshot(
|
||||
connector: connector,
|
||||
contactsSignature: contactsSignature,
|
||||
markerSignature: Object.hashAll(markerParts),
|
||||
batterySignature: batterySignature,
|
||||
uiSignature: Object.hash(
|
||||
connector.isConnected,
|
||||
connector.selfLatitude,
|
||||
connector.selfLongitude,
|
||||
connector.currentFreqHz,
|
||||
connector.currentBwHz,
|
||||
connector.currentSf,
|
||||
connector.currentTxPower,
|
||||
connector.getTotalContactsUnreadCount(),
|
||||
connector.getTotalChannelsUnreadCount(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _MapConnectorSnapshot &&
|
||||
contactsSignature == other.contactsSignature &&
|
||||
markerSignature == other.markerSignature &&
|
||||
batterySignature == other.batterySignature &&
|
||||
uiSignature == other.uiSignature;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
contactsSignature,
|
||||
markerSignature,
|
||||
batterySignature,
|
||||
uiSignature,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeMarkersCacheKey {
|
||||
final int contactsSignature;
|
||||
final int visibleContactsSignature;
|
||||
final int batterySignature;
|
||||
final _Freshness freshness;
|
||||
final double timeFilterHours;
|
||||
final bool keyPrefixEnabled;
|
||||
final String keyPrefix;
|
||||
final bool showDiscoveryContacts;
|
||||
final int batteryChemistrySignature;
|
||||
final bool showLabels;
|
||||
final String? selectedKey;
|
||||
final double zoom;
|
||||
final bool overlapsMode;
|
||||
final bool showRepeaters;
|
||||
final bool showChatNodes;
|
||||
final bool showOtherNodes;
|
||||
final bool isBuildingPathTrace;
|
||||
|
||||
const _NodeMarkersCacheKey({
|
||||
required this.contactsSignature,
|
||||
required this.visibleContactsSignature,
|
||||
required this.batterySignature,
|
||||
required this.freshness,
|
||||
required this.timeFilterHours,
|
||||
required this.keyPrefixEnabled,
|
||||
required this.keyPrefix,
|
||||
required this.showDiscoveryContacts,
|
||||
required this.batteryChemistrySignature,
|
||||
required this.showLabels,
|
||||
required this.selectedKey,
|
||||
required this.zoom,
|
||||
required this.overlapsMode,
|
||||
required this.showRepeaters,
|
||||
required this.showChatNodes,
|
||||
required this.showOtherNodes,
|
||||
required this.isBuildingPathTrace,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is _NodeMarkersCacheKey &&
|
||||
contactsSignature == other.contactsSignature &&
|
||||
visibleContactsSignature == other.visibleContactsSignature &&
|
||||
batterySignature == other.batterySignature &&
|
||||
freshness == other.freshness &&
|
||||
timeFilterHours == other.timeFilterHours &&
|
||||
keyPrefixEnabled == other.keyPrefixEnabled &&
|
||||
keyPrefix == other.keyPrefix &&
|
||||
showDiscoveryContacts == other.showDiscoveryContacts &&
|
||||
batteryChemistrySignature == other.batteryChemistrySignature &&
|
||||
showLabels == other.showLabels &&
|
||||
selectedKey == other.selectedKey &&
|
||||
zoom == other.zoom &&
|
||||
overlapsMode == other.overlapsMode &&
|
||||
showRepeaters == other.showRepeaters &&
|
||||
showChatNodes == other.showChatNodes &&
|
||||
showOtherNodes == other.showOtherNodes &&
|
||||
isBuildingPathTrace == other.isBuildingPathTrace;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
contactsSignature,
|
||||
visibleContactsSignature,
|
||||
batterySignature,
|
||||
freshness,
|
||||
timeFilterHours,
|
||||
keyPrefixEnabled,
|
||||
keyPrefix,
|
||||
showDiscoveryContacts,
|
||||
batteryChemistrySignature,
|
||||
showLabels,
|
||||
selectedKey,
|
||||
zoom,
|
||||
overlapsMode,
|
||||
showRepeaters,
|
||||
showChatNodes,
|
||||
showOtherNodes,
|
||||
isBuildingPathTrace,
|
||||
);
|
||||
}
|
||||
|
||||
class _GuessedLocation {
|
||||
final Contact contact;
|
||||
final LatLng position;
|
||||
|
||||
@@ -207,6 +207,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||
}
|
||||
|
||||
void _toggleScan(MeshCoreConnector connector) {
|
||||
if (PlatformInfo.isWeb) {
|
||||
// flutter_blue_plus has no web backend, so a BLE scan silently no-ops in
|
||||
// the browser. Tell the user instead of leaving them staring at a button.
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.scanner_bluetoothWebUnsupported),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (connector.state == MeshCoreConnectionState.scanning) {
|
||||
connector.stopScan();
|
||||
} else {
|
||||
|
||||
@@ -725,18 +725,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.setNodeName(controller.text);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.settings_nodeNameUpdated),
|
||||
ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final name = controller.text.trim();
|
||||
return TextButton(
|
||||
onPressed: name.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
Navigator.pop(context);
|
||||
await connector.setNodeName(name);
|
||||
await connector.refreshDeviceInfo();
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.settings_nodeNameUpdated),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -386,6 +386,10 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
// Cancelling the browser's serial port picker is a normal user action, not
|
||||
// an error — don't show a scary red toast (and never leak the raw
|
||||
// DOMException text).
|
||||
if (_isUserCancelledPortPicker(error)) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(_friendlyErrorMessage(error)),
|
||||
@@ -393,6 +397,16 @@ class _UsbScreenState extends State<UsbScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isUserCancelledPortPicker(Object error) {
|
||||
if (error is StateError &&
|
||||
error.message.contains('No USB serial device selected')) {
|
||||
return true;
|
||||
}
|
||||
final text = error.toString();
|
||||
return text.contains('No port selected by the user') ||
|
||||
text.contains("Failed to execute 'requestPort'");
|
||||
}
|
||||
|
||||
String _friendlyErrorMessage(Object error) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
|
||||
@@ -114,6 +114,36 @@ class NotificationService {
|
||||
return _isInitialized;
|
||||
}
|
||||
|
||||
// Cached "are we allowed to post notifications" result. Null = not yet
|
||||
// determined. Avoids calling _notifications.show() when it would only throw
|
||||
// "You must request notifications permissions first" (every web build, and
|
||||
// Android 13+ before the user grants the permission).
|
||||
bool? _canNotify;
|
||||
|
||||
Future<bool> _ensureCanNotify() async {
|
||||
if (!await _ensureInitialized()) return false;
|
||||
final cached = _canNotify;
|
||||
if (cached != null) return cached;
|
||||
|
||||
// flutter_local_notifications has no web backend, so show() always throws.
|
||||
// Skip silently instead of logging an error per incoming message.
|
||||
if (kIsWeb) return _canNotify = false;
|
||||
|
||||
// On Android 13+ notifications require an explicit grant; reflect the real
|
||||
// OS state so we don't spam failed show() calls when denied.
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final enabled = await androidPlugin.areNotificationsEnabled();
|
||||
return _canNotify = enabled ?? false;
|
||||
}
|
||||
|
||||
// iOS/macOS request permission during initialize(); desktop has no gate.
|
||||
return _canNotify = true;
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -126,7 +156,8 @@ class NotificationService {
|
||||
>();
|
||||
if (androidPlugin != null) {
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
// iOS permissions are requested during initialization
|
||||
@@ -140,7 +171,8 @@ class NotificationService {
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
_canNotify = granted ?? false;
|
||||
return _canNotify!;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -165,7 +197,7 @@ class NotificationService {
|
||||
String? contactId,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'messages',
|
||||
@@ -215,7 +247,7 @@ class NotificationService {
|
||||
required String contactType,
|
||||
String? contactId,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'adverts',
|
||||
@@ -265,7 +297,7 @@ class NotificationService {
|
||||
int? channelIndex,
|
||||
int? badgeCount,
|
||||
}) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
@@ -545,7 +577,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
|
||||
if (!await _ensureInitialized()) return;
|
||||
if (!await _ensureCanNotify()) return;
|
||||
|
||||
// Group by type
|
||||
final messages = batch
|
||||
|
||||
@@ -33,12 +33,14 @@ class UsbSerialService {
|
||||
String? _connectedPortLabel;
|
||||
FlSerial? _serial;
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel =>
|
||||
_connectedPortLabel ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
Object? get lastError => _lastError;
|
||||
bool get _useAndroidUsbHost =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
bool get _useDesktopFlSerial =>
|
||||
@@ -434,6 +436,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
void _addFrameError(Object error, [StackTrace? stackTrace]) {
|
||||
_lastError = error;
|
||||
if (_frameController.isClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ class UsbSerialService {
|
||||
static const Map<String, String> _knownUsbNames = <String, String>{
|
||||
'2886:1667': 'Seeed Wio Tracker L1',
|
||||
};
|
||||
|
||||
/// USB-to-UART bridge chips whose hardware auto-reset circuit requires DTR
|
||||
/// to be held asserted after open (otherwise the MCU resets). Native-USB-CDC
|
||||
/// boards (nRF52840/Adafruit 0x239A, Espressif native 0x303A, Seeed 0x2886)
|
||||
/// tie DTR to the bootloader/reset line, so asserting it re-enumerates and
|
||||
/// drops the device ("The device has been lost"); they must be left alone.
|
||||
static const Set<int> _uartBridgeVendorIds = <int>{
|
||||
0x10C4, // Silicon Labs CP210x
|
||||
0x1A86, // QinHeng CH340 / CH9102
|
||||
0x0403, // FTDI
|
||||
0x067B, // Prolific PL2303
|
||||
};
|
||||
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
|
||||
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
|
||||
static final Map<String, JSObject> _authorizedPortsByKey =
|
||||
@@ -34,12 +46,14 @@ class UsbSerialService {
|
||||
String _requestPortLabel = 'Choose USB Device';
|
||||
String _fallbackDeviceName = 'Web Serial Device';
|
||||
AppDebugLogService? _debugLogService;
|
||||
Object? _lastError;
|
||||
|
||||
UsbSerialStatus get status => _status;
|
||||
String? get activePortKey => _connectedPortKey;
|
||||
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
|
||||
Stream<Uint8List> get frameStream => _frameController.stream;
|
||||
bool get isConnected => _status == UsbSerialStatus.connected;
|
||||
Object? get lastError => _lastError;
|
||||
|
||||
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
|
||||
bool get _isSupported => _navigator.has('serial');
|
||||
@@ -74,6 +88,7 @@ class UsbSerialService {
|
||||
}
|
||||
|
||||
_status = UsbSerialStatus.connecting;
|
||||
_lastError = null;
|
||||
_frameDecoder.reset();
|
||||
|
||||
try {
|
||||
@@ -282,16 +297,30 @@ class UsbSerialService {
|
||||
..['flowControl'] = 'none'.toJS;
|
||||
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
|
||||
|
||||
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
|
||||
try {
|
||||
final signals = JSObject()
|
||||
..['dataTerminalReady'] = true.toJS
|
||||
..['requestToSend'] = false.toJS;
|
||||
await port
|
||||
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||
.toDart;
|
||||
} catch (_) {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
// Only UART-bridge chips (CP210x/CH340/FTDI/PL2303) need DTR held high to
|
||||
// avoid the auto-reset circuit firing on open. Native-USB-CDC boards
|
||||
// (e.g. nRF52840/Adafruit) tie DTR to the reset line — toggling it there
|
||||
// re-enumerates the device and Web Serial reports "The device has been
|
||||
// lost". Leave their signals untouched.
|
||||
final vendorId = _portInfo(port)?.usbVendorId;
|
||||
final isUartBridge =
|
||||
vendorId != null && _uartBridgeVendorIds.contains(vendorId);
|
||||
_debugLogService?.info(
|
||||
'Open: vendorId=${vendorId == null ? 'unknown' : '0x${vendorId.toRadixString(16)}'} '
|
||||
'uartBridge=$isUartBridge (DTR ${isUartBridge ? 'asserted' : 'left default'})',
|
||||
tag: 'USB Serial',
|
||||
);
|
||||
if (isUartBridge) {
|
||||
try {
|
||||
final signals = JSObject()
|
||||
..['dataTerminalReady'] = true.toJS
|
||||
..['requestToSend'] = false.toJS;
|
||||
await port
|
||||
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
|
||||
.toDart;
|
||||
} catch (_) {
|
||||
// setSignals may not be supported on all browsers/devices.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,13 +413,21 @@ class UsbSerialService {
|
||||
} catch (error, stackTrace) {
|
||||
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
|
||||
if (_status == UsbSerialStatus.connected) {
|
||||
// The transport is dead — reflect that in status immediately so a
|
||||
// concurrent connect handshake fails fast instead of waiting for a
|
||||
// SELF_INFO that can never arrive.
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
_lastError = error;
|
||||
_addFrameError(error, stackTrace);
|
||||
}
|
||||
} finally {
|
||||
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
|
||||
_releaseLock(reader);
|
||||
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
|
||||
_addFrameError(StateError('USB serial connection closed'));
|
||||
_status = UsbSerialStatus.disconnected;
|
||||
final closedError = StateError('USB serial connection closed');
|
||||
_lastError = closedError;
|
||||
_addFrameError(closedError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"bg": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -41,6 +42,7 @@
|
||||
],
|
||||
|
||||
"de": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -82,6 +84,7 @@
|
||||
],
|
||||
|
||||
"es": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -123,6 +126,7 @@
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -164,6 +168,7 @@
|
||||
],
|
||||
|
||||
"hu": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -205,6 +210,7 @@
|
||||
],
|
||||
|
||||
"it": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -246,6 +252,7 @@
|
||||
],
|
||||
|
||||
"ja": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -287,6 +294,7 @@
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -328,6 +336,7 @@
|
||||
],
|
||||
|
||||
"nl": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -369,6 +378,7 @@
|
||||
],
|
||||
|
||||
"pl": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -410,6 +420,7 @@
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -451,6 +462,7 @@
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -492,6 +504,7 @@
|
||||
],
|
||||
|
||||
"sk": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -533,6 +546,7 @@
|
||||
],
|
||||
|
||||
"sl": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -574,6 +588,7 @@
|
||||
],
|
||||
|
||||
"sv": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -615,6 +630,7 @@
|
||||
],
|
||||
|
||||
"uk": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
@@ -656,6 +672,7 @@
|
||||
],
|
||||
|
||||
"zh": [
|
||||
"scanner_bluetoothWebUnsupported",
|
||||
"map_searchHint",
|
||||
"map_activity",
|
||||
"map_online",
|
||||
|
||||
Reference in New Issue
Block a user