diff --git a/README.md b/README.md index 63ea6b2cc..23f50fb9c 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ MeshCore runs simultaneously alongside Meshtastic and Reticulum. Use the protoco **Room servers (BBS)** — **Rooms** tab (RF only; not MQTT) -- Login to room-server contacts (guest read-only or admin post); **Continue read-only** when the server guest password is empty (zero-byte password, matching the official Android client) +- Login to room-server contacts; **blank** guest password for read-only when allowed; **`"hello"`** as the default read/write guest password; **Continue read-only** also sends blank - Post plain UTF-8 after login; inbound **SignedPlain** pushes show author prefix stripped in the UI - **Remember password**, **Auto-sync** (periodic re-login while connected, minimum 60 minutes per room), per-room unread badges (sidebar **Rooms** tab; separate from **Chat** badges) - Room admin CLI / ACL setperm on the **Repeaters** tab (room rows); Rooms Members still call `get acl` via the same CLI path. Session/login queue and path sync in `meshcoreRoom*.ts` — see [docs/meshcore-meshtastic-parity.md](docs/meshcore-meshtastic-parity.md#meshcore-room-servers) and [Troubleshooting](docs/troubleshooting.md#meshcore-room-server-login-posts-and-windows-10) diff --git a/docs/agents/meshcore-rooms.md b/docs/agents/meshcore-rooms.md index 3520139e7..a0c0d0f96 100644 --- a/docs/agents/meshcore-rooms.md +++ b/docs/agents/meshcore-rooms.md @@ -2,12 +2,12 @@ Deep subsystem reference for AI assistants. Open this when a task touches MeshCore Rooms login/post, session RPCs, saved passwords, auto-sync scheduling, or room wire text. Hard rules live in [`AGENTS.md`](../../AGENTS.md). -- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). +- **UI:** `RoomsPanel.tsx` — login overlay, post composer (`ChatComposer`), auto-sync toggles, Members ACL refresh (`get acl`); **Manage on Repeaters** jumps to Repeaters & Rooms ops (CLI / setperm / infra). Sidebar badge via `meshcoreRoomsUnread.ts` (`mesh-client:meshcoreRoomsUnread`). Blank guest **Login** = read-only when allowed; **`"hello"`** is the default read/write guest password; **Continue read-only** also sends blank. **Cancel login** aborts the outer login op (path resolve + SendLogin) via `meshcoreBeginRoomLoginOperation` / `meshcoreCancelRoomLogin`; queue TX-spacing waits are interruptible so Cancel does not sit out the 60s mesh spacing. - **Ops (not on Rooms tab):** Ping / Status / Neighbors / Sensor / CLI / ACL setperm live on `RepeatersPanel` (mixed Repeater + Room table). **Open room** on a room row jumps back to Rooms. Guest passwords stay in `meshcoreRoomCredentialStorage`; ops admin uses `meshcoreInfraAdminSecrets` (admin-only records allowed). - **Room remote CLI:** needs room **admin** password via SendLogin ACL (`meshcoreCancelRoomLogin` then admin login inside `sendRepeaterCliCommand`); guest BBS login is not enough (`repeatersPanel.roomCliNeedsAdminPassword`). - **Post length (single-packet):** room posts use the shared `ChatComposer` (`variant="room"`) and are **single-packet** like MeshCore chat — over the room payload limit (`getMeshcoreRoomPayloadLimit` in `chatComposerLimits.ts`) the send is blocked with the same `meshcoreSingleNotice` callout rather than split into `[i/N]` parts, and the ~5s fast-send advisory applies. See [`chat.md`](chat.md) (Composer limits / send cadence). -- **Session / RPC:** `meshcoreRoomSession.ts`, `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomLogoutRpc.ts`, `meshcoreRoomLoginQueue.ts`, `meshcoreRoomLoginPathSync.ts`, `meshcoreRoomSentWait.ts`; credentials in `meshcoreRoomCredentialStorage.ts` / `meshcoreRoomSyncStorage.ts`. +- **Session / RPC:** `meshcoreRoomSession.ts`, `meshcoreRoomLoginRpc.ts`, `meshcoreRoomPostRpc.ts`, `meshcoreRoomLogoutRpc.ts`, `meshcoreRoomLoginQueue.ts`, `meshcoreRoomLoginPathSync.ts`, `meshcoreRoomSentWait.ts`; credentials in `meshcoreRoomCredentialStorage.ts` / `meshcoreRoomSyncStorage.ts`. **LoginSuccess:** prefer v7+ `permissions` (PERM_ACL_*); `reserved` is the legacy admin/guest hint (0=RW, 1=admin, 2=guest) — do not treat it as ACL. **Hops:** `resolveMeshcoreRoomLoginHopsAway` uses route bytes when UI hops are 0, and treats sticky UI hops with an empty path as 0-hop so SendLogin is not blocked as noRoute. **History catch-up:** `resetMeshcoreRoomCompanionSyncSinceForCatchUp` remove+re-adds the companion contact (firmware zeroes `sync_since` only on new contacts) when this device has no local last-post watermark; add is retried if remove already succeeded so the contact is not left deleted. - **Saved passwords:** `meshcoreRoomSavedSecrets.ts` — sidebar/overlay **Forget** / **Stop auto-login**; `forgetMeshcoreRoomSavedSecrets` clears credential + disables auto-login and auto-sync; `disableMeshcoreRoomLoginAfterAuthFailure` disables both without clearing password or in-memory failure UI. -- **Scheduler:** `meshcoreRoomSyncScheduler.ts` + `useMeshcoreRuntime.ts` — periodic re-login (Auto-sync, RF-only); single-flight ticks; background route resolve uses `skipTrace` / `MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS`. Auth failure disables auto-sync and auto-login via `disableMeshcoreRoomLoginAfterAuthFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). +- **Scheduler:** `meshcoreRoomSyncScheduler.ts` + `useMeshcoreRuntime.ts` — periodic re-login (Auto-sync, RF-only) goes through the same `loginRoom` path as the UI (path sync, catch-up, abort, waiting-message drain) with `schedulerFastPath` (skipTrace / `MESHCORE_ROOM_SYNC_ROUTE_RESOLVE_FAST_MS`). Single-flight ticks. Auth failure disables auto-sync and auto-login via `disableMeshcoreRoomLoginAfterAuthFailure`. Timeouts in `timeConstants.ts` (shorter for TCP / 0-hop). - **Connect auto-login:** `meshcoreRoomAutoLoginOnConnect.ts` + `useMeshcoreRuntime.ts` — 500ms debounce (`MESHCORE_ROOM_AUTO_LOGIN_DEBOUNCE_MS`) when Room contacts appear (`roomAutoLoginReadyKey`, including pubkey-ready). Overlapping triggers share one pass (`runMeshcoreRoomAutoLoginSingleFlight`) and dirty-re-run so a second room hydrating mid-pathSync still logs in. Disconnect bumps a generation so a dying pass must not `SendLogin` on the new conn. Skips logged-in, queued (`meshcoreIsRoomLoginQueued`), failed (`getMeshcoreRoomAutoLoginFailure`), and not-yet-hydrated contacts. - **Wire text:** `meshcoreChannelText.ts` — channel/DM/room payloads, SignedPlain inbound strip, tapback/reply lines; `meshcoreGifWire.ts` — Open `g:GIFID`; `meshcoreOpenReaction.ts` — Open `r:HASH:INDEX`. Default companion keyless outbound; opt-in Open wire via Radio `meshcoreOpenWireCompatEnabled` (UI on `RadioPanel`). diff --git a/docs/agents/reticulum.md b/docs/agents/reticulum.md index d38b58d36..84208a621 100644 --- a/docs/agents/reticulum.md +++ b/docs/agents/reticulum.md @@ -12,7 +12,7 @@ Deep subsystem reference for AI assistants. Open this when a task touches the Re - **Propagation mode / sync:** Network → Propagation nodes owns Off/Auto/Manual (default **Off**; persisted values including legacy App-panel `auto` are honored). Auto one-time syncs via `startPropagationSyncCascade` + sidecar `destination_hash` sync in order: **finite-hop discovered** (no Add/Preferred) → **configured remotes** → **unknown-hop discovered** → local-prop (skips remotes when no enabled interfaces); runtime hook `useReticulumPropagationAutoSync`. Sidecar `start_propagation_sync` is **client `/get`-primary** (inbox retrieval; UI progress from `PropagationClient`) — peer `/offer` inventory push stays on the local-host peer loop when serving (avoids AwaitingResponse hangs against non-peer remotes with a nonempty messagestore). Hard-fails with `PROPAGATION_PATH_UNKNOWN` when `ensure_path_for_direct` fails after announce settle (same path gate as offer probe). Manual uses Preferred, else picks the best configured remote **for that sync only** (no Preferred write), then the remaining remotes, then local-prop. Off = **no PN support**: `startPropagationSyncCascade` returns early (per-row Sync is disabled in UI), `hasEffectiveReticulumPropagationTarget` / `hasReticulumPnCascadeCapacity` are false, `ReticulumPropagationNotice` is hidden, and the sidecar disarms the outbound PN plus empties cascade candidates (`propagation_mode` in `mesh_client_stack.json`, `POST /api/v1/propagation/mode`, `candidates_for_propagation_mode`); renderer pushes the mode on change and on sidecar-ready. **Ignore for Auto:** `POST/DELETE /api/v1/propagation/auto-blacklist` persists `propagation_auto_blacklist` (32-hex, cap 256); filters Auto sync ranking **and** Auto deposit (`auto_discovered_candidates` + configured retain in Auto); Manual Prefer/Sync still allowed. Ownership: mode in renderer localStorage (+ sidecar mirror); blacklist + deposit candidates in sidecar; sync cascade orchestration in `reticulumPropagationAutoApply.ts`; `startSync` attempt stamps must be unique across same-ms supersession. `reticulumPropagationStore` / `reticulumPropagationSync.ts` — Establishing stall (~45s) + hard ceiling (~180s), auto-sync interval from last success with failure cooldown, error keys for identity / non-PN / path-unknown / peering stamp; stamps `lastPropagationSyncAttemptAt` / `activePropagationSyncAttemptAt` for WS correlation. Cancel mid-`/get` must call `PropagationClient::abort_transfer` (rsLXMF overlay) or the next Sync stays `PROPAGATION_RETRIEVE_BUSY`. Silent Host `/get` terminal clear must only drop the latch when `propagation_sync_target` still equals that peer. **Nothing-to-sync is not a failure:** when the cascade contacts no node it writes `syncNoTarget` / `syncLocalLoading` / `syncRetrieveBusy` (never overwriting a real error from an attempted node), the local row reports sidecar `status: "loading"` while the messagestore reads (`local_propagation_status` + `PropagationBridge::messagestore_load_pending`, per-row Sync disabled), and the 30 s tick calls `refreshFromSidecar` while `hasPropagationCascadeCandidate` is false so a fresh stack recovers on its own — `refreshFromSidecar` must **not** clear the active attempt while `sync.active`. Debug snapshot `propagationClient` exposes mode/preferred/autoTarget/resolvedSyncTargetId/autoBlacklist. **Auto also deposits on Discovered PNs:** sidecar `auto_discovered_candidates` (`pn_cascade.rs`, Auto only, cap 3, hop-sorted with `MAX_PLAUSIBLE_PROPAGATION_HOPS=32`, skips inactive / self / already-configured / Auto-blacklist / over `max_peering_cost`) appends after configured remotes and before local-prop, rebuilt from the shared `rebuild_pn_cascade_candidates` helper in `live.rs`; capacity helpers count non-blacklisted discovered rows in Auto. **Chat notice dismiss:** `chatNoticeDismissed` with **Don't show again** / Network toggle. **Named sync target:** `startSync` stamps `syncTargetId`. **Attempts settle before the cascade advances:** `startSync` returns `accepted` | `deferred` | `failed`. Soft-defer (`PROPAGATION_SYNC_OUTBOUND_BUSY` / `PROPAGATION_RETRIEVE_BUSY` / `PROPAGATION_STACK_NOT_LIVE`) advances **without** 15‑min backoff; all-remote soft-defer + local-only settle must **not** advance `lastPropagationSyncAt` as a full success. Remote budget `PROPAGATION_CASCADE_BUDGET_MS` (5 min); per-attempt ~60s; single-flight cascade. Auto `/api/v1/interfaces` probe **fails closed** (treat read/rate-limit as no interfaces → local-only settle). **Retrieval vs peer sync:** User Sync progress is **client `/get`-primary**. Peer `/offer` runs only on the **local Host peer loop**. Logs: `propagation-retrieve` (`retrieve_mode=get|get_post_peer|get_periodic|local`); peer-offer `propagation-sync … peer_outcome=*` (**not** retrieval). `local-prop` Sync uses `drain_local_inbox` and returns `PROPAGATION_STACK_NOT_LIVE` when live is absent. - **Host PN fabric → Chat (lxmd-style glue):** When local Host is enabled, mesh-client is both PN and end-client on rsLXMF (not a second lxmd). Path: outbound deposit → host peer `/offer` push (generation-gated; lxmd terminal bookkeeping: `sync_complete` / `mark_offer_generation_processed` / `take_handled_updates` + `save_peer`); inbound peer Resource accept → `request_inbox_drain` → maintenance `drain_local_inbox` → `delivery_callback` → Chat; after host peer `/offer` Completes for peer `P`, sequenced **silent** client `/get` (`retrieve_mode=get_post_peer`); while serving and quiet, **~90s** periodic silent `/get` round-robin over peered remotes (Prefer/outbound first, `retrieve_mode=get_periodic`) for inbox catch-up — not a timed empty re-`/offer`. Re-`/offer` when `offer_generation` advances, offer policy changes, or a partial sync left work; maintenance polls every ~2s but only _starts_ work when idle. Guards: coalesce drain; one internal `/get`; skip when user Sync target / outbound deposit owns the hash or `sync_active` / `client_download_active`. Do **not** re-attach peer `/offer` to the Sync button. Dual full-index exchange in one Link stays upstream rsLXMF; remote inventory for re-propagation arrives when peers `/offer` to our serve path. - **PN hosting:** Network **Advanced PN hosting** / `ReticulumPnHostingDangerZone`; shared `pnHostingPolicy.ts` + sidecar `pn_hosting_policy.rs` / `pn_hosting_apply.rs`; `POST /api/v1/propagation/hosting-policy`; rsLXMF policy-setters overlay ([ratspeak/rsLXMF#6](https://github.com/ratspeak/rsLXMF/pull/6)). Messagestore loads in background on live attach; enabled `local-prop` serve/announce waits until load completes. -- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). See [../reticulum.md#interface-modes](../reticulum.md#interface-modes). +- **Interface modes:** rnsd `mode` via `reticulumInterfaceMode.ts` + sidecar `normalize_interface_mode` (keep catalogs in sync — `pnpm run check:reticulum-interface-modes` in pre-commit/`release.sh`); add defaults TCP/UDP/I2P → `boundary`, RNode → `access_point`; UI in `ReticulumInterfacesPanel`; default hub presets add/repair missing mode to `boundary` (do not overwrite valid non-boundary). Discoverable + Full/Roaming/Boundary stamps `ignore_config_warnings = Yes` (`reconcile_ignore_config_warnings` / `repair_ignore_config_warnings_in_config`) so RNS does not auto-correct runtime mode to Access Point; Connection shows **Effective: Access Point** when live `runtime_mode` still diverges. See [../reticulum.md#interface-modes](../reticulum.md#interface-modes) and [../reticulum.md#rmap-publish-and-interface-mode](../reticulum.md#rmap-publish-and-interface-mode). - **Share instance defaults:** missing keys bootstrap to `share_instance = No` / `instance_name = mesh-client` (does not overwrite explicit Yes/`default`); SharedInstanceClient banner + `disable_share_instance` repair; offline lint via `reticulum:validateConfig` / Network **Check config** / `pnpm run reticulum:config:check` - **LXMF replies:** sidecar stamps `FIELD_REPLY_TO` / capped `FIELD_REPLY_QUOTE` before sign; renderer ingest/Chat use `reticulum_reply_to_hash` + quote preview + jump-by-hash - **RNode flasher timeouts:** `RNODE_COMMAND_TIMEOUT_MS` (30 s serial), `RNODE_BT_PAIRING_TIMEOUT_MS` (90 s BLE pairing), `ESP32_FLASH_STALL_TIMEOUT_MS` / `NRF52_DFU_STALL_TIMEOUT_MS` (60 s no-progress → `ESP32_FLASH_STALLED` / `NRF52_DFU_STALLED`); humanized via `flasherErrorHumanize.ts` diff --git a/docs/meshcore-meshtastic-parity.md b/docs/meshcore-meshtastic-parity.md index 2443bdbf7..d2628ff1a 100644 --- a/docs/meshcore-meshtastic-parity.md +++ b/docs/meshcore-meshtastic-parity.md @@ -52,11 +52,11 @@ Shared UI gates use `ProtocolCapabilities` in [`src/renderer/lib/radio/BaseRadio Room servers (`hw_model === 'Room'`, contact type 3) are BBS nodes on the mesh. The companion radio must be connected over **RF** (BLE, serial, or TCP); **MQTT does not carry room login/post**. -**Login:** Guest read-only uses **zero password bytes** when the server guest password is empty (**Continue read-only** on the login overlay). Admin login uses the configured password. Login RPC, queue, and path sync live under `src/renderer/lib/meshcoreRoom*.ts` (e.g. [`meshcoreRoomLoginRpc.ts`](../src/renderer/lib/meshcoreRoomLoginRpc.ts), [`meshcoreRoomLoginQueue.ts`](../src/renderer/lib/meshcoreRoomLoginQueue.ts)); timeouts are shorter on TCP and 0-hop paths ([`timeConstants.ts`](../src/renderer/lib/timeConstants.ts)). +**Login:** Blank guest Login sends **zero password bytes** (read-only when `allow.read.only` is on; same wire as **Continue read-only**). **`hello`** is the default read/write guest password. Admin login uses the configured password. LoginSuccess ACL is the companion v7+ `permissions` byte (PERM_ACL_*); `reserved` is only the legacy admin/guest hint (do not treat as ACL). Login RPC, queue, and path sync live under `src/renderer/lib/meshcoreRoom*.ts` (e.g. [`meshcoreRoomLoginRpc.ts`](../src/renderer/lib/meshcoreRoomLoginRpc.ts), [`meshcoreRoomLoginQueue.ts`](../src/renderer/lib/meshcoreRoomLoginQueue.ts)); timeouts are shorter on TCP and 0-hop paths ([`timeConstants.ts`](../src/renderer/lib/timeConstants.ts)). **Posts:** Outbound room posts use plain UTF-8 (`TXT_TYPE_PLAIN`) after login and are **single-packet** — mesh-client does not emit multi-part `[i/N]` room posts; over-limit text is blocked in the composer (same rationale as chat/DM). Inbound **SignedPlain** pushes include a four-byte author prefix; the **Rooms** UI strips it, and inbound multi-part from other clients is still merged for display. Posts appear in the **Rooms** tab (channel `-2`), not Chat channel pills. -**Sync:** Firmware only **pushes new posts** after login (no history backfill). **Auto-sync** re-logs in on a timer while the radio stays connected (minimum 60 minutes per room, [`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts)). Saved passwords: SQLite `app_settings` (same pattern as Meshtastic remote admin keys). Session clears on disconnect. +**Sync:** After login, the room server pushes posts newer than the companion `sync_since` watermark (ring buffer, typically ~32). mesh-client resets that watermark when the device has no local last-post time yet, then drains waiting messages. **Auto-sync** re-logs in on a timer while the radio stays connected (minimum 60 minutes per room, [`meshcoreRoomSyncScheduler.ts`](../src/renderer/lib/meshcoreRoomSyncScheduler.ts)). Saved passwords: SQLite `app_settings` (same pattern as Meshtastic remote admin keys). Session clears on disconnect. **Unread:** Room BBS traffic increments the **Rooms** sidebar badge ([`meshcoreRoomsUnread.ts`](../src/renderer/lib/meshcoreRoomsUnread.ts)) and system-tray unread when backgrounded; it does not increment the **Chat** tab badge. diff --git a/docs/reticulum.md b/docs/reticulum.md index 0a37b4738..6c8836fa1 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -266,17 +266,31 @@ Config lives under `userData/reticulum/config/` (rnsd INI). The Connection tab s rnsd **mode** applies to every interface type (TCP, I2P, RNode, …), not only radios. It controls path expiry, path seeking, and announce propagation between segments. -| Mode | Path Expiry | Path Seeking | When to use on an RNode | -| ---------------- | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------- | -| `full` | 7 days | No | Primary nodes, repeaters, fixed infrastructure — full mesh participation, auto-announces, routes traffic | -| `access_point` | 24 hours | Yes | High-elevation or wide-area radios serving intermittent users — stays quiet until queried, fast path expiry | -| `roaming` | 6 hours | Yes | Mobile/handheld or vehicular RNodes moving between coverage areas — short path timeouts, active discovery | -| `boundary` | 7 days | No | Linking a LoRa segment to an Internet/TCP backbone — isolates local traffic, selective announce bridging | -| `gateway` | 7 days | Yes | Client-facing interface that resolves unknown paths on behalf of connected nodes | -| `point_to_point` | 7 days | No | Dedicated direct link between exactly two nodes — no routing, no announce propagation | +| Mode | Path Expiry | Path Seeking | When to use on an RNode | +| ---------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `full` | 7 days | No | Primary nodes, repeaters, fixed infrastructure — full mesh participation, auto-announces, routes traffic. **Note:** with RMAP `discoverable = Yes`, RNS silently rewrites to Access Point unless `ignore_config_warnings = Yes` (mesh-client stamps this when publish is on and you keep Full/Roaming/Boundary). | +| `access_point` | 24 hours | Yes | High-elevation or wide-area radios serving intermittent users — stays quiet until queried, fast path expiry | +| `roaming` | 6 hours | Yes | Mobile/handheld or vehicular RNodes moving between coverage areas — short path timeouts, active discovery | +| `boundary` | 7 days | No | Linking a LoRa segment to an Internet/TCP backbone — isolates local traffic, selective announce bridging | +| `gateway` | 7 days | Yes | Client-facing interface that resolves unknown paths on behalf of connected nodes | +| `point_to_point` | 7 days | No | Dedicated direct link between exactly two nodes — no routing, no announce propagation | > When adding an RNode, the type default is **Access point** — a good starting point for most radio deployments. Switch to **Roaming** if the radio moves, or **Full** for a fixed high-participation LoRa node. For Internet/LoRa bridging, keep the RNode on Access point (or Roaming) and set the TCP/I2P hub interface to **Boundary** (mesh-client’s hub default). +### RMAP publish and interface mode + +Reticulum requires discoverable interfaces to run as **Gateway**, **Internal**, or **Access Point**. Enabling RMAP publish (`discoverable = Yes`) on an RNode configured as Full, Roaming, Boundary, or Point-to-point causes rsReticulum to **auto-correct the runtime mode to Access Point** unless the interface INI sets `ignore_config_warnings = Yes`. + +mesh-client **honors an explicit Full (or other non-AP/Gateway) mode with publish** by writing that opt-out when you enable RMAP on such an interface (and on stack start for existing configs). Connection shows an **Effective: Access Point** badge when live stats still differ from the configured mode (e.g. before restart). + +| Config | Runtime after restart | Idle RF announce behavior | +| --------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Full + RMAP publish | Full (with opt-out) | LXMF delivery announces egress the RNode on `announce_interval_sec` (default **1 hour**), plus RMAP discovery announces on the per-interface interval | +| Access point + RMAP publish | Access point | Quiet until queried; RMAP discovery announces only; 24 h path expiry | +| Full, publish off | Full | LXMF delivery announces on the radio; no RMAP publish | + +Turning publish off clears `ignore_config_warnings` when it is no longer needed. Do not hand-edit the flag unless you know you need the upstream opt-out. + `point_to_point` is omitted from the official Reticulum manual’s interface-modes section but is defined in RNS (`MODE_POINT_TO_POINT`) and included in mesh-client’s mode catalog. Inbound “other apps / nodes connect to me” on this machine uses **Share instance** under **Network → stack settings** (runtime `SharedInstanceServer`), not a separate TCP server interface type. See also [diagnostics.md](diagnostics.md) SharedInstance notes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3e3cd1af1..2d937034c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -892,9 +892,9 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Guest / read-only login fails with timeout or "rejected"**: -- When the room server **guest password is empty**, use **Continue read-only** on the Rooms login overlay. That sends **zero password bytes** (same as the official Android app). **Login** with an empty guest field is disabled; it would send the default **`hello`** password instead. -- When the server **does** configure a guest password, enter that value in the guest field and click **Login** (some communities use **`hello`**). -- **Room admin CLI** (**Repeaters** tab → room row CLI; needs the room **admin** password via SendLogin ACL, not guest BBS login): many stock room servers use **`hello`** as the default admin password when none was configured. Save the admin password under Repeaters → password for that room. Do not confuse that with **Continue read-only** on the Rooms login overlay, which sends zero bytes for blank guest servers. +- Try **blank** Login (or **Continue read-only**) for **read-only** when the room has `allow.read.only` on. That sends **zero password bytes**. +- For **read/write** (post), try the default guest password **`hello`**, or the room’s configured guest password. MeshCore has no passwordless write mode. +- **Room admin CLI** (**Repeaters** tab → room row CLI; needs the room **admin** password via SendLogin ACL, not guest BBS login): many stock room servers use **`hello`** as the default admin password when none was configured. Save the admin password under Repeaters → password for that room. - Logs showing push **`0x86`** (frame 134) mean **LoginFail** (wrong password or ACL denied). **Room login** rejects immediately on a prefix-matched LoginFail. **Repeater admin login** keeps waiting for a possible LoginSuccess (meshcore.js behavior on congested links); timeout after LoginFail alone is reported as timeout, not wrong password. - **Admin password** working while guest/read-only fails usually means the guest password on the server does not match what the client sent, or ACL denies read-only login. - If the room **changed its password** and mesh-client keeps trying to log in, open the **Rooms** tab: expand **Saved passwords** in the sidebar (or use the login overlay for the selected room). Use **Stop auto-login** to stop connect-time retries while keeping the old password stored, or **Forget saved password** to clear the stored guest/admin password and turn off auto-login and auto-sync. After a wrong-password failure, auto-login is turned off automatically until you log in again with **Remember password** or re-enable it. @@ -920,12 +920,13 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **No room history after login**: -- Firmware only **pushes new posts** after a successful login; it does not backfill old BBS messages. Enable **Auto-sync** on the Rooms tab to periodic re-login while connected. +- Room servers keep a **short ring buffer** of recent posts and push anything newer than your companion’s `sync_since` watermark after LoginSuccess. mesh-client resets that watermark (remove+re-add contact) when this device has **no local last-post watermark** yet, then drains waiting messages after login. +- Posts older than the ring (or already past `sync_since`) will not appear. Enable **Auto-sync** on the Rooms tab to periodically re-login while connected so you stay current. - mesh-client stores posts received while you are logged in on **this device**. Quitting the app or staying logged out for days means posts from that period will not appear later unless they were persisted locally. See the **Rooms** tab history note under Auto-sync. **pyMC / server console shows posts but Rooms tab does not (cross-client)**: -- The room **server log** (e.g. pyMC) lists everything the BBS stored. mesh-client and the official app only show posts **pushed to your radio while you are logged in** to that room (see above). Posts made before your login, or while you were logged out, will not appear until someone posts again after you re-login (or use **Auto-sync** to periodic re-login). +- The room **server log** (e.g. pyMC) lists everything the BBS stored. mesh-client and the official app only show posts **pushed to your radio while you are logged in** to that room (see above). Posts made before your login, or while you were logged out, will not appear until someone posts again after you re-login (or use **Auto-sync** to periodically re-login). - For a fair test: keep **both** clients logged into the **same room** while connected, then post from one side and confirm the other receives it within ~30 seconds on RF. - mesh-client sends outbound room posts as **`TXT_TYPE_PLAIN`**; inbound BBS pushes use **`TXT_TYPE_SIGNED_PLAIN`** (author prefix stripped in the Rooms UI). @@ -935,7 +936,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Read-only → write upgrade does nothing**: -- After **Continue read-only**, use **Upgrade access** (or **Login** with the guest password) so the client sends a fresh **SendLogin** with `forceRelogin`. Enter the real guest password (often **`hello`**); empty field Login is disabled on the main overlay to avoid sending `hello` when the server expects blank read-only login. +- After **Continue read-only**, use **Upgrade access** and enter the guest password (often **`hello`**) so the client sends a fresh **SendLogin** with `forceRelogin`. An empty field cannot upgrade write access. **Long room posts show as `[1/2]`, `[2/2]`…**: @@ -954,7 +955,7 @@ The client deduplicates overlapping RF and MQTT hears within **5 minutes** (cros **Retest checklist (after upgrading from a known-good build)**: 1. Connect MeshCore over TCP or BLE; confirm nodes load. -2. Open **Rooms** → with **empty guest password** on the server, click **Continue read-only** (not **Login** with an empty field). With a configured guest password, enter it and click **Login**. +2. Open **Rooms** → try **blank** Login for read-only (or **Continue read-only**). For posting, try **`hello`** (default read/write guest password) or the room’s guest password. 3. Post as admin; confirm the post appears in the **official Android app** on the same room (SignedPlain BBS path). 4. Confirm room posts appear in **Rooms** with unread badges (not Chat channel pills). 5. On **Connection** tab, receive a **channel** message on a channel you are not viewing → sidebar **Chat** badge and red pill on that channel when you open Chat. diff --git a/package.json b/package.json index df430f93d..8f2afc6a1 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "@stoprocent/noble": "^2.7.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "@zip.js/zip.js": "^2.8.47", + "@zip.js/zip.js": "^2.8.51", "builder-util-runtime": "9.7.0", "dompurify": "^3.4.13", "electron-updater": "^6.8.9", diff --git a/patches/@liamcottle__meshcore.js@1.14.0.patch b/patches/@liamcottle__meshcore.js@1.14.0.patch index 673139607..ff440781b 100644 --- a/patches/@liamcottle__meshcore.js@1.14.0.patch +++ b/patches/@liamcottle__meshcore.js@1.14.0.patch @@ -1,5 +1,5 @@ diff --git a/src/buffer_reader.js b/src/buffer_reader.js -index 9142fbc..2f8cf2d 100644 +index 9142fbc9fda24c07082d63ced24117a35b29dc93..2f8cf2d68d1c32c1d7cc489f33527c9ad89545d4 100644 --- a/src/buffer_reader.js +++ b/src/buffer_reader.js @@ -24,7 +24,10 @@ class BufferReader { @@ -15,7 +15,7 @@ index 9142fbc..2f8cf2d 100644 readCString(maxLength) { diff --git a/src/connection/connection.js b/src/connection/connection.js -index 541ce73..50a3bef 100644 +index 541ce732e52444d094043e49fef295752da3d7bc..209edb4f2cccff89efee83c708796a5c226dff8d 100644 --- a/src/connection/connection.js +++ b/src/connection/connection.js @@ -338,6 +338,14 @@ class Connection extends EventEmitter { @@ -49,7 +49,34 @@ index 541ce73..50a3bef 100644 } } -@@ -498,15 +512,25 @@ class Connection extends EventEmitter { +@@ -457,10 +471,22 @@ class Connection extends EventEmitter { + } + + onLoginSuccessPush(bufferReader) { +- this.emit(Constants.PushCodes.LoginSuccess, { +- reserved: bufferReader.readByte(), // reserved +- pubKeyPrefix: bufferReader.readBytes(6), // 6 bytes of public key this login success is from +- }); ++ // Companion v7+ LoginSuccess: legacyFlag(1) + pubKeyPrefix(6) + serverTimestamp(4) + aclPermissions(1) + firmwareVerLevel(1). ++ // Room servers put PERM_ACL_* in aclPermissions; legacyFlag is NOT the ACL (0=RW/RO, 1=admin, 2=guest). ++ const reserved = bufferReader.readByte(); // legacy is_admin / guest hint (data[6]) ++ const pubKeyPrefix = bufferReader.readBytes(6); ++ const payload = { ++ reserved: reserved, ++ pubKeyPrefix: pubKeyPrefix, ++ }; ++ if (bufferReader.getRemainingBytesCount() >= 5) { ++ payload.serverTimestamp = bufferReader.readUInt32LE(); ++ payload.permissions = bufferReader.readByte(); // PERM_ACL_* after timestamp ++ if (bufferReader.getRemainingBytesCount() >= 1) { ++ payload.firmwareVerLevel = bufferReader.readByte(); ++ } ++ } ++ this.emit(Constants.PushCodes.LoginSuccess, payload); + } + + onStatusResponsePush(bufferReader) { +@@ -498,15 +524,25 @@ class Connection extends EventEmitter { onTraceDataPush(bufferReader) { const reserved = bufferReader.readByte(); const pathLen = bufferReader.readUInt8(); @@ -81,7 +108,7 @@ index 541ce73..50a3bef 100644 }); } -@@ -586,11 +610,30 @@ class Connection extends EventEmitter { +@@ -586,11 +622,30 @@ class Connection extends EventEmitter { } onDeviceInfoResponse(bufferReader) { @@ -116,7 +143,7 @@ index 541ce73..50a3bef 100644 }); } -@@ -2401,6 +2444,33 @@ class Connection extends EventEmitter { +@@ -2401,6 +2456,33 @@ class Connection extends EventEmitter { }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e92d40fd7..4a1783d27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ overrides: patchedDependencies: '@jsr/meshtastic__core@2.6.6': 93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652 '@jsr/meshtastic__transport-web-serial@0.2.5': 27a2418bae8605e0e5ab6f1fcfd391abd9dc3110433da5754e934f38475dab74 - '@liamcottle/meshcore.js@1.14.0': 3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11 + '@liamcottle/meshcore.js@1.14.0': cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49 debug@4.4.3: cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37 readable-stream@4.7.0: 96023d9278085d7490d08bce1a5079dd202f7782a0daa66fa81c6b1424ef8ab1 usb@2.18.0: 6b746e2d49b9b006a88aec5bed7a13c629d7f5ba7b40e9f1e039136754c32533 @@ -54,8 +54,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 '@zip.js/zip.js': - specifier: ^2.8.47 - version: 2.8.47 + specifier: ^2.8.51 + version: 2.8.51 builder-util-runtime: specifier: 9.7.0 version: 9.7.0(supports-color@8.1.1) @@ -128,7 +128,7 @@ importers: version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@liamcottle/meshcore.js': specifier: ^1.14.0 - version: 1.14.0(patch_hash=3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11)(supports-color@8.1.1) + version: 1.14.0(patch_hash=cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49)(supports-color@8.1.1) '@meshtastic/core': specifier: npm:@jsr/meshtastic__core@^2.6.6 version: '@jsr/meshtastic__core@2.6.6(patch_hash=93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652)(buffer@6.0.3)' @@ -1476,8 +1476,8 @@ packages: '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} - '@zip.js/zip.js@2.8.47': - resolution: {integrity: sha512-zQzidM6FD1teXUXL3Xy7g+b7k3jybQArLRVi2mIPt74JtxBVVPheDCW5q6nhEr4Pq+myXHoC9GsHIgxGQ6T8ow==} + '@zip.js/zip.js@2.8.51': + resolution: {integrity: sha512-xCEMRBwBh6JPMQKy5D/RE0Ra9NJvrvjcCHNXo+NzMYCdZy3R0oAX9zJKRb1c393HNMbLYfoD2MRSPgB8L6Jskw==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} abbrev@4.0.0: @@ -1630,8 +1630,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.13: - resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + baseline-browser-mapping@2.11.14: + resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==} engines: {node: '>=6.0.0'} hasBin: true @@ -2044,8 +2044,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.405: - resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==} + electron-to-chromium@1.5.406: + resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -5086,7 +5086,7 @@ snapshots: transitivePeerDependencies: - buffer - '@liamcottle/meshcore.js@1.14.0(patch_hash=3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11)(supports-color@8.1.1)': + '@liamcottle/meshcore.js@1.14.0(patch_hash=cb795713dfd60da6415b04d0a81a863876163d355ca946b1a50440f0c38e2f49)(supports-color@8.1.1)': dependencies: '@noble/curves': 1.9.7 serialport: 13.0.0(supports-color@8.1.1) @@ -5787,7 +5787,7 @@ snapshots: '@yarnpkg/lockfile@1.1.0': {} - '@zip.js/zip.js@2.8.47': {} + '@zip.js/zip.js@2.8.51': {} abbrev@4.0.0: {} @@ -5992,7 +5992,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.13: {} + baseline-browser-mapping@2.11.14: {} bidi-js@1.0.3: dependencies: @@ -6027,9 +6027,9 @@ snapshots: browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.11.13 + baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.405 + electron-to-chromium: 1.5.406 node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) @@ -6430,7 +6430,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.405: {} + electron-to-chromium@1.5.406: {} electron-updater@6.8.9(supports-color@8.1.1): dependencies: diff --git a/reticulum-sidecar/src/stack/auto_path_policy.rs b/reticulum-sidecar/src/stack/auto_path_policy.rs index bef4efca3..cd9d5455e 100644 --- a/reticulum-sidecar/src/stack/auto_path_policy.rs +++ b/reticulum-sidecar/src/stack/auto_path_policy.rs @@ -291,6 +291,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: vec![], discoverable, latitude, @@ -303,6 +304,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: HashMap::default(), diff --git a/reticulum-sidecar/src/stack/config.rs b/reticulum-sidecar/src/stack/config.rs index 95775e0de..d50866774 100644 --- a/reticulum-sidecar/src/stack/config.rs +++ b/reticulum-sidecar/src/stack/config.rs @@ -61,6 +61,7 @@ const KNOWN_IFACE_CONFIG_KEYS: &[&str] = &[ "network_name", "passphrase", "flow_control", + "ignore_config_warnings", ]; fn is_known_iface_config_key(key: &str) -> bool { @@ -452,6 +453,7 @@ fn interface_block_to_row(block: &IniBlock) -> Option { } } }), + runtime_mode: None, seed_addresses, discoverable: block.get_bool("discoverable"), latitude: block.get("latitude").and_then(|v| v.parse().ok()), @@ -470,6 +472,7 @@ fn interface_block_to_row(block: &IniBlock) -> Option { } else { None }, + ignore_config_warnings: block.get_bool("ignore_config_warnings"), tx_queue_used: None, tx_queue_max: None, extra_config: { @@ -560,6 +563,9 @@ fn interface_row_to_block(row: &InterfaceRow) -> IniBlock { block.set("flow_control", &bool_to_ini(v)); } } + if let Some(v) = row.ignore_config_warnings { + block.set("ignore_config_warnings", &bool_to_ini(v)); + } // Preserve unknown keys; typed fields take priority on key collision. // Skip keys/values with line breaks so API/disk corruption cannot inject INI sections. @@ -744,9 +750,67 @@ fn apply_discovery_patch( validate_lat_lon(lat, lon)?; } } + reconcile_ignore_config_warnings(row); Ok(()) } +/// Map live `GetInterfaceStats.mode` Debug names (`AccessPoint`, `Full`, …) +/// to canonical rnsd mode strings. Naive `to_lowercase()` is wrong +/// (`AccessPoint` → `accesspoint`). Unknown / empty → `None`. +pub(crate) fn live_interface_runtime_mode(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let canonical = match trimmed { + "Full" | "full" => "full", + "AccessPoint" | "access_point" | "ap" | "AP" => "access_point", + "PointToPoint" | "point_to_point" => "point_to_point", + "Roaming" | "roaming" => "roaming", + "Boundary" | "boundary" => "boundary", + "Gateway" | "gateway" | "gw" | "GW" => "gateway", + "Internal" | "internal" => "internal", + other => { + // Accept already-canonical values; reject Debug leftovers. + let lower = other.to_ascii_lowercase(); + match lower.as_str() { + "full" | "access_point" | "point_to_point" | "roaming" | "boundary" | "gateway" + | "internal" => return Some(lower), + _ => return None, + } + } + }; + Some(canonical.to_string()) +} + +/// Modes RNS allows for discoverable interfaces without `ignore_config_warnings`. +fn mode_allows_discovery_without_opt_out(mode: &str) -> bool { + matches!(mode, "access_point" | "gateway") +} + +/// Stamp or clear `ignore_config_warnings` so discoverable + Full/Roaming/Boundary +/// keeps the configured mode (upstream RNS opt-out). Omitted mode does not write +/// the flag (RNode type default is AP). Clear when discoverable is off or mode is +/// already AP/Gateway. Unrecognized non-empty mode is left untouched. +pub(crate) fn reconcile_ignore_config_warnings(row: &mut InterfaceRow) { + let discoverable = row.discoverable == Some(true); + let raw_mode = row.mode.as_deref().map(str::trim).filter(|m| !m.is_empty()); + let Some(raw_mode) = raw_mode else { + row.ignore_config_warnings = None; + return; + }; + let Ok(Some(canonical)) = normalize_interface_mode(raw_mode) else { + return; + }; + + let should_opt_out = discoverable && !mode_allows_discovery_without_opt_out(&canonical); + if should_opt_out { + row.ignore_config_warnings = Some(true); + } else { + row.ignore_config_warnings = None; + } +} + const I2P_B32_SUFFIX: &str = ".b32.i2p"; fn validate_i2p_peers(peers: &str) -> Result<(), String> { @@ -825,6 +889,7 @@ pub fn add_interface_to_config( callsign: req.callsign.clone(), id_interval: req.id_interval, mode, + runtime_mode: None, seed_addresses: req.seed_addresses.clone(), discoverable: req.discoverable, latitude: req.latitude, @@ -840,12 +905,14 @@ pub fn add_interface_to_config( flow_control: req .flow_control .or_else(|| default_flow_control_for_iface_type(&req.iface_type)), + ignore_config_warnings: req.ignore_config_warnings, tx_queue_used: None, tx_queue_max: None, extra_config: req.extra_config.clone(), }; apply_preset_defaults(&mut row); + reconcile_ignore_config_warnings(&mut row); let content = read_config(config_dir)?; let mut parsed = parse_config(&content)?; @@ -960,6 +1027,10 @@ pub fn update_interface_in_config( apply_preset_defaults(&mut row); } + // Mode / discoverable may have changed outside apply_discovery_patch (mode + // patch, or discovery fields omitted). Always reconcile before write. + reconcile_ignore_config_warnings(&mut row); + if row.iface_type == "i2p" { validate_i2p_peers(row.host.as_deref().unwrap_or(""))?; } @@ -1087,6 +1158,32 @@ pub fn repair_rnode_radio_fields_in_config(config_dir: &Path) -> Result Result { + let content = read_config(config_dir)?; + let mut parsed = parse_config(&content)?; + let mut changed = false; + for block in &mut parsed.interfaces { + let Some(mut row) = interface_block_to_row(block) else { + continue; + }; + let before = row.ignore_config_warnings; + reconcile_ignore_config_warnings(&mut row); + if row.ignore_config_warnings != before { + *block = interface_row_to_block(&row); + changed = true; + } + } + if changed { + write_config(config_dir, &serialize_config(&parsed))?; + } + Ok(changed) +} + /// One-time migration: enable `flow_control` on RF interfaces that omit the key, /// so existing configs get TX backpressure. Explicit `False`/`No` is preserved. /// Non-RF blocks are never touched (no key injected). @@ -2589,6 +2686,7 @@ target_port = 4242 callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -2601,6 +2699,7 @@ target_port = 4242 network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: { @@ -3204,4 +3303,473 @@ target_port = 4242 assert!(!disk.contains("flow_control"), "{disk}"); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn ignore_config_warnings_key_is_known() { + assert!(is_known_iface_config_key("ignore_config_warnings")); + assert!(is_known_iface_config_key("Ignore_Config_Warnings")); + } + + #[test] + fn live_interface_runtime_mode_maps_debug_names() { + let cases = [ + ("Full", Some("full")), + ("AccessPoint", Some("access_point")), + ("PointToPoint", Some("point_to_point")), + ("Roaming", Some("roaming")), + ("Boundary", Some("boundary")), + ("Gateway", Some("gateway")), + ("Internal", Some("internal")), + ("full", Some("full")), + ("access_point", Some("access_point")), + ("ap", Some("access_point")), + ("", None), + (" ", None), + ("NotAMode", None), + // Naive to_lowercase would produce "accesspoint" — must not match. + ("accesspoint", None), + ]; + for (raw, expected) in cases { + assert_eq!( + live_interface_runtime_mode(raw).as_deref(), + expected, + "raw={raw:?}" + ); + } + } + + #[test] + fn ignore_config_warnings_parsed_as_typed_not_extra_config() { + let content = r#" +[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +discoverable = Yes +ignore_config_warnings = Yes +"#; + let rows = interfaces_from_parsed(&parse_config(content).unwrap()); + let rnode = rows.iter().find(|r| r.iface_type == "rnode").unwrap(); + assert_eq!(rnode.ignore_config_warnings, Some(true)); + assert!(!rnode.extra_config.contains_key("ignore_config_warnings")); + } + + #[test] + fn ignore_config_warnings_round_trips() { + for (value, expected_ini) in [ + (true, "ignore_config_warnings = Yes"), + (false, "ignore_config_warnings = No"), + ] { + let dir = fresh_config_dir(); + let added = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some("/dev/ttyUSB0".into()), + callsign: Some("N0CALL".into()), + mode: Some("full".into()), + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + ignore_config_warnings: Some(value), + ..Default::default() + }, + ) + .unwrap(); + // add_interface reconciles to Some(true) for Full+discoverable regardless + // of the request's false — B1 always stamps Yes when needed. + let disk = read_config(&dir).unwrap(); + if value { + assert!(disk.contains(expected_ini), "{disk}"); + } else { + // reconcile overwrites explicit No on Full+discoverable. + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + } + let rows = interfaces_from_config_dir(&dir).unwrap(); + let row = rows.iter().find(|r| r.id == added.id).unwrap(); + assert_eq!(row.ignore_config_warnings, Some(true)); + assert!(!row.extra_config.contains_key("ignore_config_warnings")); + let _ = fs::remove_dir_all(&dir); + } + } + + #[test] + fn reconcile_stamps_for_full_discoverable_clears_for_ap() { + let mut row = InterfaceRow { + id: "x".into(), + name: "RNode".into(), + iface_type: "rnode".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: Some("/dev/ttyUSB0".into()), + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: Some("full".into()), + runtime_mode: None, + seed_addresses: Vec::new(), + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + height: None, + discovery_name: None, + announce_interval_min: None, + connectable: None, + reachable_on: None, + network_name: None, + passphrase: None, + flow_control: Some(true), + ignore_config_warnings: None, + tx_queue_used: None, + tx_queue_max: None, + extra_config: HashMap::new(), + }; + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, Some(true)); + + for mode in ["roaming", "boundary", "point_to_point"] { + row.mode = Some(mode.into()); + row.ignore_config_warnings = None; + reconcile_ignore_config_warnings(&mut row); + assert_eq!( + row.ignore_config_warnings, + Some(true), + "mode={mode} should stamp opt-out" + ); + } + + row.mode = Some("access_point".into()); + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, None); + + row.mode = Some("gateway".into()); + row.ignore_config_warnings = Some(true); + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, None); + + row.mode = Some("full".into()); + row.discoverable = Some(false); + row.ignore_config_warnings = Some(true); + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, None); + + // Omitted mode (RNode default AP) must not stamp. + row.mode = None; + row.discoverable = Some(true); + row.ignore_config_warnings = Some(true); + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, None); + } + + #[test] + fn update_discovery_stamps_and_clears_ignore_config_warnings() { + let dir = fresh_config_dir(); + let added = add_interface_to_config( + &dir, + &AddInterfaceRequest { + iface_type: "rnode".into(), + name: Some("RNode".into()), + serial_port: Some("/dev/ttyUSB0".into()), + callsign: Some("N0CALL".into()), + mode: Some("full".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(added.ignore_config_warnings, None); + + let updated = update_interface_in_config( + &dir, + &added.id, + &UpdateInterfacePatch { + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.ignore_config_warnings, Some(true)); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + + let cleared = update_interface_in_config( + &dir, + &added.id, + &UpdateInterfacePatch { + discoverable: Some(false), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.ignore_config_warnings, None); + let disk = read_config(&dir).unwrap(); + assert!(!disk.contains("ignore_config_warnings"), "{disk}"); + + // Gateway + discoverable does not need the opt-out. + let gw = update_interface_in_config( + &dir, + &added.id, + &UpdateInterfacePatch { + mode: Some("gateway".into()), + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(gw.ignore_config_warnings, None); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn update_preserves_extra_config_when_stamping_ignore_warnings() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +forward_interval = 300 +"#, + ) + .unwrap(); + let id = interface_id_from_name("LoRa"); + update_interface_in_config( + &dir, + &id, + &UpdateInterfacePatch { + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + ..Default::default() + }, + ) + .unwrap(); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + assert!(disk.contains("forward_interval = 300"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_ignore_config_warnings_stamps_full_discoverable() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +"#, + ) + .unwrap(); + assert!(repair_ignore_config_warnings_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + assert!(!repair_ignore_config_warnings_in_config(&dir).unwrap()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_ignore_config_warnings_skips_ap_discoverable() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = access_point +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +"#, + ) + .unwrap(); + assert!(!repair_ignore_config_warnings_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(!disk.contains("ignore_config_warnings"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_ignore_config_warnings_clears_stale_yes_on_ap() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = access_point +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +ignore_config_warnings = Yes +"#, + ) + .unwrap(); + assert!(repair_ignore_config_warnings_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(!disk.contains("ignore_config_warnings"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_ignore_config_warnings_overwrites_explicit_no_on_full_discoverable() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +ignore_config_warnings = No +"#, + ) + .unwrap(); + assert!(repair_ignore_config_warnings_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn repair_ignore_config_warnings_leaves_tcp_untouched() { + let dir = fresh_config_dir(); + write_config( + &dir, + r#"[interfaces] +[[Hub]] +type = TCPClientInterface +interface_enabled = Yes +name = Hub +target_host = example.org +target_port = 4242 +mode = boundary +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +"#, + ) + .unwrap(); + // Boundary + discoverable would stamp on any type; TCP is allowed to stamp too + // per reconcile (not RF-gated). Assert it does stamp so behavior is documented. + assert!(repair_ignore_config_warnings_in_config(&dir).unwrap()); + let disk = read_config(&dir).unwrap(); + assert!(disk.contains("ignore_config_warnings = Yes"), "{disk}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn tcp_discoverable_boundary_stamps_opt_out() { + // Non-RF types still get the opt-out when mode would be autocorrected. + let mut row = InterfaceRow { + id: "tcp".into(), + name: "Hub".into(), + iface_type: "tcp".into(), + enabled: true, + status: "up".into(), + host: Some("example.org".into()), + port: Some(4242), + preset: None, + serial_port: None, + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: Some("boundary".into()), + runtime_mode: None, + seed_addresses: Vec::new(), + discoverable: Some(true), + latitude: Some(1.0), + longitude: Some(2.0), + height: None, + discovery_name: None, + announce_interval_min: None, + connectable: None, + reachable_on: None, + network_name: None, + passphrase: None, + flow_control: None, + ignore_config_warnings: None, + tx_queue_used: None, + tx_queue_max: None, + extra_config: HashMap::new(), + }; + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, Some(true)); + } + + #[test] + fn reconcile_unrecognized_mode_preserves_existing_opt_out() { + let mut row = InterfaceRow { + id: "rnode".into(), + name: "LoRa".into(), + iface_type: "rnode".into(), + enabled: true, + status: "up".into(), + host: None, + port: None, + preset: None, + serial_port: Some("/dev/ttyUSB0".into()), + frequency: None, + bandwidth: None, + txpower: None, + spreading_factor: None, + coding_rate: None, + callsign: None, + id_interval: None, + mode: Some("not_a_real_mode".into()), + runtime_mode: None, + seed_addresses: Vec::new(), + discoverable: Some(true), + latitude: Some(40.0), + longitude: Some(-105.0), + height: None, + discovery_name: None, + announce_interval_min: None, + connectable: None, + reachable_on: None, + network_name: None, + passphrase: None, + flow_control: None, + ignore_config_warnings: Some(true), + tx_queue_used: None, + tx_queue_max: None, + extra_config: HashMap::new(), + }; + reconcile_ignore_config_warnings(&mut row); + assert_eq!(row.ignore_config_warnings, Some(true)); + } } diff --git a/reticulum-sidecar/src/stack/config_audit.rs b/reticulum-sidecar/src/stack/config_audit.rs index edcbd7a33..209191b15 100644 --- a/reticulum-sidecar/src/stack/config_audit.rs +++ b/reticulum-sidecar/src/stack/config_audit.rs @@ -329,6 +329,36 @@ fn audit_rmap_discovery( Some("edit"), )); } + + // Discoverable + Full/Roaming/Boundary (etc.) is silently rewritten to AP + // by RNS unless ignore_config_warnings is set. Omitted mode defaults to AP + // for RNode — no issue in that case. + if is_local_rnode_publish_target(row) { + let mode = row.mode.as_deref().map(str::trim).filter(|m| !m.is_empty()); + if let Some(mode) = mode { + let normalized = mode.to_ascii_lowercase(); + let canonical = match normalized.as_str() { + "ap" => "access_point", + "gw" => "gateway", + other => other, + }; + let discovery_safe = matches!(canonical, "access_point" | "gateway"); + let opt_out = row.ignore_config_warnings == Some(true); + if !discovery_safe && !opt_out { + issues.push(issue( + "rmap_mode_autocorrect", + "warning", + Some(row.id.clone()), + Some(row.name.clone()), + format!( + "Interface \"{}\" is discoverable with mode {} — RNS will auto-correct to Access Point unless ignore_config_warnings is set", + row.name, canonical + ), + Some("edit"), + )); + } + } + } } if any_discoverable && !stack_settings.enable_transport { @@ -647,6 +677,7 @@ target_port = 4242 callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -659,6 +690,7 @@ target_port = 4242 network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -755,4 +787,118 @@ target_port = 4242 assert!(!issues.iter().any(|i| i.kind.starts_with("rmap_"))); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn rmap_mode_autocorrect_when_full_discoverable_without_opt_out() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_audit_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_sample_config( + &dir, + r#"[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +discoverable = Yes +latitude = 40.0 +longitude = -105.0 + +[[RMAP World]] +type = TCPClientInterface +interface_enabled = Yes +name = RMAP World +target_host = rmap.world +target_port = 4242 +"#, + ); + let rows = config::interfaces_from_config_dir(&dir).unwrap(); + let settings = StackSettings { + enable_transport: true, + ..Default::default() + }; + let issues = audit_config(&dir, &rows, &settings, false).unwrap(); + assert!(issues.iter().any(|i| i.kind == "rmap_mode_autocorrect")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rmap_mode_autocorrect_absent_when_opt_out_set() { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_audit_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_sample_config( + &dir, + r#"[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = full +discoverable = Yes +latitude = 40.0 +longitude = -105.0 +ignore_config_warnings = Yes + +[[RMAP World]] +type = TCPClientInterface +interface_enabled = Yes +name = RMAP World +target_host = rmap.world +target_port = 4242 +"#, + ); + let rows = config::interfaces_from_config_dir(&dir).unwrap(); + let settings = StackSettings { + enable_transport: true, + ..Default::default() + }; + let issues = audit_config(&dir, &rows, &settings, false).unwrap(); + assert!(!issues.iter().any(|i| i.kind == "rmap_mode_autocorrect")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn rmap_mode_autocorrect_absent_for_ap_or_gateway() { + for mode in ["access_point", "gateway", "roaming"] { + let dir = std::env::temp_dir().join(format!("mesh_reticulum_audit_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).unwrap(); + write_sample_config( + &dir, + &format!( + r#"[[LoRa]] +type = RNodeInterface +enabled = Yes +port = /dev/ttyUSB0 +mode = {mode} +discoverable = Yes +latitude = 40.0 +longitude = -105.0 + +[[RMAP World]] +type = TCPClientInterface +interface_enabled = Yes +name = RMAP World +target_host = rmap.world +target_port = 4242 +"# + ), + ); + let rows = config::interfaces_from_config_dir(&dir).unwrap(); + let settings = StackSettings { + enable_transport: true, + ..Default::default() + }; + let issues = audit_config(&dir, &rows, &settings, false).unwrap(); + if mode == "roaming" { + assert!( + issues.iter().any(|i| i.kind == "rmap_mode_autocorrect"), + "roaming should warn" + ); + } else { + assert!( + !issues.iter().any(|i| i.kind == "rmap_mode_autocorrect"), + "mode={mode} should not warn" + ); + } + let _ = fs::remove_dir_all(&dir); + } + } } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 3cb644b0e..5283b9ac4 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -117,6 +117,15 @@ pub(crate) fn live_interface_tx_queue_fields( } } +/// Live `GetInterfaceStats.mode` is only meaningful while the interface is online. +pub(crate) fn live_interface_runtime_mode_if_online(online: bool, mode: &str) -> Option { + if online { + config::live_interface_runtime_mode(mode) + } else { + None + } +} + pub struct LiveBridge { config_dir: PathBuf, storage_dir: PathBuf, @@ -2502,6 +2511,9 @@ impl LiveBridge { callsign: None, id_interval: None, mode: None, + runtime_mode: live_interface_runtime_mode_if_online( + s.online, &s.mode, + ), seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -2514,6 +2526,7 @@ impl LiveBridge { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used, tx_queue_max, extra_config: std::collections::HashMap::new(), @@ -4150,6 +4163,7 @@ impl LiveBridge { callsign: None, id_interval: None, mode: None, + runtime_mode: live_interface_runtime_mode_if_online(s.online, &s.mode), seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -4162,6 +4176,7 @@ impl LiveBridge { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used, tx_queue_max, extra_config: std::collections::HashMap::new(), @@ -6715,6 +6730,7 @@ mod nomad_private_first_failover_tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: vec![], discoverable, latitude, @@ -6727,6 +6743,7 @@ mod nomad_private_first_failover_tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::default(), @@ -7082,7 +7099,7 @@ mod reply_field_tests { #[cfg(test)] mod live_tx_queue_fields_tests { - use super::live_interface_tx_queue_fields; + use super::{live_interface_runtime_mode_if_online, live_interface_tx_queue_fields}; #[test] fn online_stats_populate_used_and_max() { @@ -7104,4 +7121,16 @@ mod live_tx_queue_fields_tests { assert_eq!(live_interface_tx_queue_fields(true, 0, 0), (None, None)); assert_eq!(live_interface_tx_queue_fields(true, 10, 0), (None, None)); } + + #[test] + fn offline_exposes_no_runtime_mode() { + assert_eq!( + live_interface_runtime_mode_if_online(false, "AccessPoint"), + None + ); + assert_eq!( + live_interface_runtime_mode_if_online(true, "AccessPoint").as_deref(), + Some("access_point") + ); + } } diff --git a/reticulum-sidecar/src/stack/local_rnode_primary.rs b/reticulum-sidecar/src/stack/local_rnode_primary.rs index 803f60de6..7c163ea0a 100644 --- a/reticulum-sidecar/src/stack/local_rnode_primary.rs +++ b/reticulum-sidecar/src/stack/local_rnode_primary.rs @@ -302,6 +302,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -314,6 +315,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index a06bfc4b5..cbfad6b98 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -2294,6 +2294,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: vec![], discoverable, latitude, @@ -2306,6 +2307,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::default(), diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index d055d8855..fe5841cc3 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -246,6 +246,18 @@ impl StackHandle { } } + match config::repair_ignore_config_warnings_in_config(&config_dir) { + Ok(true) => { + tracing::info!( + "reconciled ignore_config_warnings for discoverable interfaces with non-AP/Gateway mode" + ); + } + Ok(false) => {} + Err(e) => { + tracing::warn!("failed to reconcile ignore_config_warnings in config: {e}"); + } + } + let mut persisted = PersistedState::load(&config_dir, &storage_dir); persisted.ensure_defaults(); if let Ok(ifaces) = config::interfaces_from_config_dir(&config_dir) { diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index 4db7dae9c..c3620c840 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -152,6 +152,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -164,6 +165,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), diff --git a/reticulum-sidecar/src/stack/path_failover.rs b/reticulum-sidecar/src/stack/path_failover.rs index efabb0214..15686af43 100644 --- a/reticulum-sidecar/src/stack/path_failover.rs +++ b/reticulum-sidecar/src/stack/path_failover.rs @@ -292,6 +292,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: vec![], discoverable, latitude, @@ -304,6 +305,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::default(), diff --git a/reticulum-sidecar/src/stack/persistence.rs b/reticulum-sidecar/src/stack/persistence.rs index 23ce1f18c..ab6a44ebe 100644 --- a/reticulum-sidecar/src/stack/persistence.rs +++ b/reticulum-sidecar/src/stack/persistence.rs @@ -284,6 +284,7 @@ impl PersistedState { callsign: req.callsign, id_interval: req.id_interval, mode: req.mode, + runtime_mode: None, seed_addresses: req.seed_addresses, discoverable: req.discoverable, latitude: req.latitude, @@ -298,6 +299,7 @@ impl PersistedState { flow_control: req .flow_control .or_else(|| super::config::default_flow_control_for_iface_type(&req.iface_type)), + ignore_config_warnings: req.ignore_config_warnings, tx_queue_used: None, tx_queue_max: None, extra_config: req.extra_config, diff --git a/reticulum-sidecar/src/stack/rf_profiles.rs b/reticulum-sidecar/src/stack/rf_profiles.rs index 053817244..89a5e8a4c 100644 --- a/reticulum-sidecar/src/stack/rf_profiles.rs +++ b/reticulum-sidecar/src/stack/rf_profiles.rs @@ -201,6 +201,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -213,6 +214,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), diff --git a/reticulum-sidecar/src/stack/types.rs b/reticulum-sidecar/src/stack/types.rs index 3d0e1a5f7..287fdea73 100644 --- a/reticulum-sidecar/src/stack/types.rs +++ b/reticulum-sidecar/src/stack/types.rs @@ -31,6 +31,11 @@ pub struct InterfaceRow { pub callsign: Option, pub id_interval: Option, pub mode: Option, + /// Effective RNS interface mode from live `GetInterfaceStats` (Debug name + /// mapped to canonical rnsd values). None when offline / unknown. Config + /// `mode` remains the user-configured value; the UI compares the two. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_mode: Option, #[serde(default)] pub seed_addresses: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -59,6 +64,11 @@ pub struct InterfaceRow { /// bursts do not overflow the bounded TX queue. Only meaningful for RF types. #[serde(default, skip_serializing_if = "Option::is_none")] pub flow_control: Option, + /// Upstream RNS opt-out: keep configured mode when `discoverable` would + /// otherwise auto-correct to Access Point / Gateway. Derived by mesh-client + /// when publish is on and mode is not AP/Gateway. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ignore_config_warnings: Option, /// Host outbound TX mpsc fill from live `GetInterfaceStats` (None when offline / unknown). #[serde(default, skip_serializing_if = "Option::is_none")] pub tx_queue_used: Option, @@ -268,6 +278,8 @@ pub struct AddInterfaceRequest { #[serde(default)] pub flow_control: Option, #[serde(default)] + pub ignore_config_warnings: Option, + #[serde(default)] pub extra_config: HashMap, } @@ -324,6 +336,7 @@ mod tx_queue_serde_tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -336,6 +349,7 @@ mod tx_queue_serde_tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: HashMap::default(), @@ -349,6 +363,8 @@ mod tx_queue_serde_tests { let obj = value.as_object().expect("object"); assert!(!obj.contains_key("tx_queue_used")); assert!(!obj.contains_key("tx_queue_max")); + assert!(!obj.contains_key("runtime_mode")); + assert!(!obj.contains_key("ignore_config_warnings")); } #[test] @@ -361,6 +377,8 @@ mod tx_queue_serde_tests { let roundtrip: InterfaceRow = serde_json::from_value(value).expect("deserialize"); assert_eq!(roundtrip.tx_queue_used, None); assert_eq!(roundtrip.tx_queue_max, None); + assert_eq!(roundtrip.runtime_mode, None); + assert_eq!(roundtrip.ignore_config_warnings, None); } #[test] @@ -376,6 +394,25 @@ mod tx_queue_serde_tests { assert_eq!(roundtrip.tx_queue_max, Some(256)); } + #[test] + fn runtime_mode_and_ignore_warnings_round_trip() { + let mut row = minimal_row(); + row.runtime_mode = Some("access_point".into()); + row.ignore_config_warnings = Some(true); + let value = serde_json::to_value(&row).expect("serialize"); + assert_eq!( + value.get("runtime_mode"), + Some(&Value::from("access_point")) + ); + assert_eq!( + value.get("ignore_config_warnings"), + Some(&Value::Bool(true)) + ); + let roundtrip: InterfaceRow = serde_json::from_value(value).expect("deserialize"); + assert_eq!(roundtrip.runtime_mode.as_deref(), Some("access_point")); + assert_eq!(roundtrip.ignore_config_warnings, Some(true)); + } + #[test] fn offline_live_statistics_yield_none() { let mut row = minimal_row(); diff --git a/reticulum-sidecar/src/stack/via.rs b/reticulum-sidecar/src/stack/via.rs index e20a7e332..4a09c6b39 100644 --- a/reticulum-sidecar/src/stack/via.rs +++ b/reticulum-sidecar/src/stack/via.rs @@ -219,6 +219,7 @@ pub fn merge_live_interfaces_with_config( live_row.callsign = cfg.callsign.clone(); live_row.id_interval = cfg.id_interval; live_row.mode = cfg.mode.clone(); + // Keep live `runtime_mode` (effective RNS mode); do not clobber with config. live_row.discoverable = cfg.discoverable; live_row.latitude = cfg.latitude; live_row.longitude = cfg.longitude; @@ -230,6 +231,8 @@ pub fn merge_live_interfaces_with_config( // Config is source of truth for IFAC / extras / BLE seeds (live stats omit them). live_row.network_name = cfg.network_name.clone(); live_row.passphrase = cfg.passphrase.clone(); + live_row.flow_control = cfg.flow_control; + live_row.ignore_config_warnings = cfg.ignore_config_warnings; live_row.extra_config = cfg.extra_config.clone(); live_row.seed_addresses = cfg.seed_addresses.clone(); // Config INI is the source of truth for user enable/disable; live stats only @@ -284,6 +287,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -296,6 +300,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -412,6 +417,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -424,6 +430,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -452,6 +459,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -464,6 +472,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -486,6 +495,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -498,6 +508,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -533,6 +544,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -545,6 +557,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -567,6 +580,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -579,6 +593,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -612,6 +627,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -624,6 +640,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -657,6 +674,7 @@ mod tests { callsign: None, id_interval: None, mode: Some("boundary".into()), + runtime_mode: None, seed_addresses: vec!["AA:BB:CC:DD:EE:FF".into()], discoverable: None, latitude: None, @@ -669,6 +687,7 @@ mod tests { network_name: Some("ttp_internal".into()), passphrase: Some("resistance202606".into()), flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: extra.clone(), @@ -691,6 +710,7 @@ mod tests { callsign: None, id_interval: None, mode: None, + runtime_mode: None, seed_addresses: Vec::new(), discoverable: None, latitude: None, @@ -703,6 +723,7 @@ mod tests { network_name: None, passphrase: None, flow_control: None, + ignore_config_warnings: None, tx_queue_used: None, tx_queue_max: None, extra_config: std::collections::HashMap::new(), @@ -749,4 +770,48 @@ mod tests { assert_eq!(merged[0].tx_queue_used, None); assert_eq!(merged[0].tx_queue_max, None); } + + #[test] + fn merge_preserves_runtime_mode_while_config_mode_wins() { + // Silent-lie case: UI config says full; live RNS rewrote to Access Point. + let mut config = sample_iface("cfg-1", "RNode USB", "rnode", true, "down"); + config.mode = Some("full".into()); + config.discoverable = Some(true); + config.ignore_config_warnings = Some(true); + config.flow_control = Some(true); + + let mut live = sample_iface("rns-0", "RNode USB", "AccessPoint", true, "up"); + live.runtime_mode = Some("access_point".into()); + + let merged = merge_live_interfaces_with_config(&[config], vec![live]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].iface_type, "rnode"); + assert_eq!(merged[0].mode.as_deref(), Some("full")); + assert_eq!(merged[0].runtime_mode.as_deref(), Some("access_point")); + assert_eq!(merged[0].ignore_config_warnings, Some(true)); + assert_eq!(merged[0].flow_control, Some(true)); + assert_eq!(merged[0].discoverable, Some(true)); + } + + #[test] + fn merge_preserves_matching_runtime_mode() { + let mut config = sample_iface("cfg-1", "RNode USB", "rnode", true, "down"); + config.mode = Some("full".into()); + let mut live = sample_iface("rns-0", "RNode USB", "Full", true, "up"); + live.runtime_mode = Some("full".into()); + let merged = merge_live_interfaces_with_config(&[config], vec![live]); + assert_eq!(merged[0].mode.as_deref(), Some("full")); + assert_eq!(merged[0].runtime_mode.as_deref(), Some("full")); + } + + #[test] + fn merge_offline_runtime_mode_stays_unset() { + let mut config = sample_iface("cfg-1", "RNode USB", "rnode", true, "up"); + config.mode = Some("full".into()); + let mut live = sample_iface("rns-0", "RNode USB", "AccessPoint", true, "down"); + live.runtime_mode = None; + let merged = merge_live_interfaces_with_config(&[config], vec![live]); + assert_eq!(merged[0].runtime_mode, None); + assert_eq!(merged[0].mode.as_deref(), Some("full")); + } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 342e3ec8d..72cb0a7c2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -157,6 +157,7 @@ import { import { resolvePanelPositionSendHandler, resolvePanelRebootHandler, + resolvePanelSetOwnerHandler, } from './lib/appPanelHandlerSelection'; import { protocolRecord, selectByProtocol } from './lib/appProtocolSelect'; import { getAppSettingsRaw, isRrcUnreadAllRoomMessagesEnabled } from './lib/appSettingsStorage'; @@ -1762,7 +1763,7 @@ function AppContent() { : meshtasticRuntime.securityConfig; const effectiveDeviceOwner = isRemoteConfigureTarget ? (meshtasticRuntime.remoteConfigSnapshot?.deviceOwner ?? null) - : meshtasticRuntime.deviceOwner; + : activeRuntime.deviceOwner; const effectiveDeviceFixedPosition = isRemoteConfigureTarget ? (meshtasticRuntime.remoteConfigSnapshot?.deviceFixedPosition ?? null) : meshtasticRuntime.deviceFixedPosition; @@ -3639,11 +3640,11 @@ function AppContent() { meshcorePanelActions.sendPositionToDevice, )} deviceOwner={effectiveDeviceOwner} - onSetOwner={ - capabilities.hasChannelConfig - ? meshtasticPanelActions.setOwner - : undefined - } + onSetOwner={resolvePanelSetOwnerHandler( + capabilities, + meshtasticPanelActions.setOwner, + meshcorePanelActions.setOwner, + )} capabilities={capabilities} meshcoreChannels={ capabilities.hasCompanionContactManagementConfig diff --git a/src/renderer/components/NodeDetailModal.test.tsx b/src/renderer/components/NodeDetailModal.test.tsx index eedb87582..ec2e6bb0d 100644 --- a/src/renderer/components/NodeDetailModal.test.tsx +++ b/src/renderer/components/NodeDetailModal.test.tsx @@ -320,6 +320,8 @@ describe('NodeDetailModal MeshCore actions', () => { const pubkeyEl = await screen.findByText(pubkeyHex); expect(pubkeyEl).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Test Node' })).toBeInTheDocument(); + expect(screen.queryByText('!abababab')).not.toBeInTheDocument(); hydrateAxeThemeColors(container); expect(await axe(container)).toHaveNoViolations(); diff --git a/src/renderer/components/NodeDetailModal.tsx b/src/renderer/components/NodeDetailModal.tsx index 53ec474c6..1a89fa5af 100644 --- a/src/renderer/components/NodeDetailModal.tsx +++ b/src/renderer/components/NodeDetailModal.tsx @@ -15,6 +15,7 @@ import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolI import { writeClipboardText } from '@/renderer/lib/writeClipboardText'; import { formatIsoDateTime } from '@/shared/formatIsoDate'; import { buildMeshcoreContactAddUri, type MeshcoreContactType } from '@/shared/meshClientDeepLink'; +import { meshcoreContactDisplayName } from '@/shared/meshcoreContactSanitize'; import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError'; import { formatMeshtasticNodeId } from '@/shared/nodeNameUtils'; @@ -46,7 +47,6 @@ import { MESHCORE_CONTACTS_CRITICAL_THRESHOLD, MESHCORE_MAX_CONTACTS, meshcoreContactTypeFromHwModel, - meshcorePubkeyShortId, meshcoreTracePathLenToHops, } from '../lib/meshcoreUtils'; import { @@ -582,13 +582,13 @@ export default function NodeDetailModal({ if (!node) return null; - const hexId = - protocol === 'meshcore' - ? (meshcorePubkeyShortId(contactPubkey) ?? formatMeshtasticNodeId(node.node_id)) - : formatMeshtasticNodeId(node.node_id); + const hexId = formatMeshtasticNodeId(node.node_id); const awaitingNodeInfo = protocol === 'meshtastic' && meshtasticNodeAwaitingNodeInfo(node, { isConnected }); - const displayName = node.short_name || node.long_name || hexId; + const displayName = + protocol === 'meshcore' + ? meshcoreContactDisplayName(node.node_id, node.long_name) + : node.short_name || node.long_name || hexId; const isOurNode = node.node_id === homeNode?.node_id; const nodeStatus = getNodeStatus(node.last_heard, nodeStaleThresholdMs, nodeOfflineThresholdMs); const nodeStatusUi = @@ -687,7 +687,9 @@ export default function NodeDetailModal({ )}
- {hexId} + {protocol !== 'meshcore' && ( + {hexId} + )} {headerHopsDisplay != null && ( { }); }); +describe('RadioPanel MeshCore Device User / Identity', () => { + async function openDeviceUserSection(user: ReturnType) { + const userDetails = [...document.querySelectorAll('details')].find((d) => { + const span = d.querySelector(':scope > summary > span'); + return span?.textContent?.trim() === 'Device User / Identity'; + }); + expect(userDetails).toBeDefined(); + await user.click(userDetails!.querySelector('summary')!); + return userDetails!; + } + + it('enables Apply and calls onSetOwner when MeshCore capabilities provide the handler', async () => { + const user = userEvent.setup(); + const onSetOwner = vi.fn().mockResolvedValue(undefined); + const { container } = render( + + + , + ); + + await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + + const nameInput = screen.getByLabelText('Name'); + await user.clear(nameInput); + await user.type(nameInput, 'WisMesh Tag'); + + const applyButton = screen.getByRole('button', { name: 'Apply Device User / Identity' }); + expect(applyButton).toBeEnabled(); + await user.click(applyButton); + + await waitFor(() => { + expect(onSetOwner).toHaveBeenCalledWith({ + longName: 'WisMesh Tag', + shortName: '', + isLicensed: false, + }); + }); + }); + + it('keeps Apply disabled when MeshCore capabilities are set but onSetOwner is missing', async () => { + const user = userEvent.setup(); + const { container } = render( + + + , + ); + + await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + + expect(screen.getByRole('button', { name: 'Apply Device User / Identity' })).toBeDisabled(); + }); + + it('prefills the MeshCore name field from deviceOwner', async () => { + const user = userEvent.setup(); + const { container } = render( + + + , + ); + + await openDeviceUserSection(user); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + + expect(screen.getByLabelText('Name')).toHaveValue('TagName'); + }); +}); + describe('RadioPanel remote target safeguards', () => { it('disables Device apply until device config slice is hydrated', async () => { const user = userEvent.setup(); diff --git a/src/renderer/components/RoomsPanel.test.tsx b/src/renderer/components/RoomsPanel.test.tsx index f329c48fb..019767092 100644 --- a/src/renderer/components/RoomsPanel.test.tsx +++ b/src/renderer/components/RoomsPanel.test.tsx @@ -11,6 +11,7 @@ import { type StarredMessage, } from '@/renderer/lib/chatPanelProtocolStorage'; import { VIRTUALIZER_SCROLL_END_THRESHOLD } from '@/renderer/lib/chatScrollUtils'; +import { serializeMeshcoreUserMessage } from '@/renderer/lib/meshcore/meshcoreMessageI18n'; import { buildMeshcoreRoomIncomingMessage } from '@/renderer/lib/meshcoreChannelText'; import { clearAllMeshcoreRoomAutoLoginFailures, @@ -64,7 +65,22 @@ vi.mock('@tanstack/react-virtual', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, opts?: Record) => { + if (key === 'meshcore.errors.roomLogin.noRoute') { + return 'No route to this room server. Trace the node from the map or wait for path adverts, then try again.'; + } + if (key === 'meshcore.errors.roomLogin.pathSyncFailedDetail') { + const detail = typeof opts?.detail === 'string' ? opts.detail : ''; + return `Could not program the route on your radio before login. Reconnect the device and try again.${detail}`; + } + if (key === 'meshcore.errors.roomLogin.timedOut') { + return 'Room login timed out. The room may be out of range or not responding.'; + } + if (key === 'roomsPanel.autoLoginFailed' && typeof opts?.error === 'string') { + return `Auto-login failed: ${opts.error}`; + } + return key; + }, }), })); @@ -167,6 +183,9 @@ describe('RoomsPanel', () => { const onLoginRoom = vi.fn().mockResolvedValue(undefined); renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + fireEvent.change(screen.getByLabelText('roomsPanel.guestPasswordLabel'), { + target: { value: 'hello' }, + }); fireEvent.click(screen.getByRole('button', { name: 'roomsPanel.upgradeAccess' })); await waitFor(() => { @@ -275,14 +294,76 @@ describe('RoomsPanel', () => { }); }); - it('disables Login when guest password field is empty', () => { + it('translates meshcore roomLogin.noRoute instead of showing the raw key', async () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x1005, 'No Route Room'); + const nodes = new Map([[room.node_id, room]]); + const onLoginRoom = vi.fn().mockRejectedValue(new Error('meshcore.errors.roomLogin.noRoute')); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + fireEvent.click(screen.getByText('roomsPanel.loginButton')); + await waitFor(() => { + expect( + screen.getByText( + 'No route to this room server. Trace the node from the map or wait for path adverts, then try again.', + ), + ).toBeInTheDocument(); + }); + expect(screen.queryByText('meshcore.errors.roomLogin.noRoute')).not.toBeInTheDocument(); + }); + + it('translates serialized pathSyncFailedDetail including radio error detail', async () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x1006, 'Path Sync Room'); + const nodes = new Map([[room.node_id, room]]); + const onLoginRoom = vi.fn().mockRejectedValue( + new Error( + serializeMeshcoreUserMessage({ + key: 'meshcore.errors.roomLogin.pathSyncFailedDetail', + params: { detail: ' (timeout)' }, + }), + ), + ); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + fireEvent.click(screen.getByText('roomsPanel.loginButton')); + await waitFor(() => { + expect( + screen.getByText( + 'Could not program the route on your radio before login. Reconnect the device and try again. (timeout)', + ), + ).toBeInTheDocument(); + }); + }); + + it('allows Login with empty guest password and sends blank', async () => { + meshcoreClearAllRoomSessions(); const room = makeRoom(0x1004, 'Empty Guest Room'); const nodes = new Map([[room.node_id, room]]); - renderRoomsPanel(nodes, { initialRoomTarget: room.node_id }); - fireEvent.change(screen.getByLabelText('roomsPanel.guestPasswordLabel'), { - target: { value: '' }, + const onLoginRoom = vi.fn().mockResolvedValue(undefined); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id, onLoginRoom }); + expect(screen.getByLabelText('roomsPanel.guestPasswordLabel')).toHaveValue(''); + expect(screen.getByText('roomsPanel.emptyGuestLoginHint')).toBeInTheDocument(); + expect(screen.getByText('roomsPanel.loginButton')).not.toBeDisabled(); + fireEvent.click(screen.getByText('roomsPanel.loginButton')); + await waitFor(() => { + expect(onLoginRoom).toHaveBeenCalledWith( + room.node_id, + '', + expect.objectContaining({ guestPassword: '' }), + ); + }); + }); + + it('disables Upgrade access when guest password field is empty', () => { + meshcoreClearAllRoomSessions(); + const room = makeRoom(0x100d, 'Upgrade Empty Room'); + const nodes = new Map([[room.node_id, room]]); + meshcoreApplyRoomSession(room.node_id, { + guestPassword: '', + adminPassword: '', + role: 'readonly', }); - expect(screen.getByText('roomsPanel.loginButton')).toBeDisabled(); + renderRoomsPanel(nodes, { initialRoomTarget: room.node_id }); + expect(screen.getByRole('button', { name: 'roomsPanel.upgradeAccess' })).toBeDisabled(); expect(screen.getByText('roomsPanel.emptyGuestLoginHint')).toBeInTheDocument(); }); @@ -810,6 +891,25 @@ describe('RoomsPanel', () => { expect(marker.className).toContain('ring-red-500'); }); + it('translates serialized auto-login failure in the sidebar marker', () => { + const room = makeRoom(0x1010, 'Serialized Auto Fail Room'); + const nodes = new Map([[room.node_id, room]]); + const serialized = serializeMeshcoreUserMessage({ + key: 'meshcore.errors.roomLogin.timedOut', + }); + setMeshcoreRoomAutoLoginFailure(room.node_id, serialized); + + renderRoomsPanel(nodes); + + expect(screen.getByLabelText('roomsPanel.autoLoginFailedAria')).toBeInTheDocument(); + expect( + screen.getByTitle( + 'Auto-login failed: Room login timed out. The room may be out of range or not responding.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText(serialized)).not.toBeInTheDocument(); + }); + it('clears auto-login failure when re-enabling auto-login on connect', async () => { const room = makeRoom(0x1023, 'Re-enable Auto Room'); const nodes = new Map([[room.node_id, room]]); diff --git a/src/renderer/components/RoomsPanel.tsx b/src/renderer/components/RoomsPanel.tsx index aa76daaf8..a2efbb50b 100644 --- a/src/renderer/components/RoomsPanel.tsx +++ b/src/renderer/components/RoomsPanel.tsx @@ -44,6 +44,7 @@ import { ROOM_LOGIN_PROGRESS_DOT } from '@/renderer/lib/connectionHeaderStatus'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { ICON_MD } from '@/renderer/lib/icons/iconClass'; import { useParentIconTrigger } from '@/renderer/lib/icons/iconMotionContext'; +import { translateMeshcoreUserMessage } from '@/renderer/lib/meshcore/meshcoreMessageI18n'; import { repairMeshcoreHydrationStaleRoomSends } from '@/renderer/lib/meshcoreDbCacheHydration'; import { type MeshcoreRoomAclEntry, @@ -71,7 +72,6 @@ import { getMeshcoreRoomSavedSecretsSummary, } from '@/renderer/lib/meshcoreRoomSavedSecrets'; import { - MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD, meshcoreCancelAllRoomLogins, meshcoreGetRoomSession, meshcoreIsRoomLoggedIn, @@ -264,7 +264,7 @@ export default function RoomsPanel({ const [selectedRoomId, setSelectedRoomId] = useState( () => initialRoomTarget ?? null, ); - const [loginPassword, setLoginPassword] = useState(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + const [loginPassword, setLoginPassword] = useState(''); /** Tracks in-flight login promises before the shared queue snapshot updates (tests / fast paths). */ const [localLoginRoomIds, setLocalLoginRoomIds] = useState>(() => new Set()); const [leaveLoadingRoomIds, setLeaveLoadingRoomIds] = useState>(() => new Set()); @@ -812,7 +812,7 @@ export default function RoomsPanel({ next.delete(nodeId); return next; }); - setLoginPassword(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + setLoginPassword(''); setRememberPassword(false); loadSyncConfig(nodeId); }, @@ -946,7 +946,7 @@ export default function RoomsPanel({ forceRelogin, }); if (rememberPassword) refreshStoredRooms(); - setLoginPassword(MESHCORE_ROOM_DEFAULT_GUEST_PASSWORD); + setLoginPassword(''); }); }, [ loginPassword, @@ -1276,16 +1276,27 @@ export default function RoomsPanel({ : savedRoomsNotLoggedInCount === 0 ? t('roomsPanel.loginAllSavedDisabledAllLoggedIn') : ''; - const loginButtonEnabled = isConnected && !guestFieldEmpty && !selectedRoomLoginLoading; - const loginButtonClass = loginButtonEnabled + /** Overlay Login may send a zero-byte password; upgrade needs a non-empty guest password. */ + const overlayLoginEnabled = isConnected && !selectedRoomLoginLoading; + const upgradeLoginEnabled = overlayLoginEnabled && !guestFieldEmpty; + const overlayLoginButtonClass = overlayLoginEnabled + ? 'border-readable-green bg-readable-green w-full cursor-pointer rounded border px-3 py-2 text-sm font-semibold text-white hover:bg-readable-green/90' + : 'w-full cursor-not-allowed rounded border border-gray-600 bg-gray-700 px-3 py-2 text-sm font-medium text-gray-500'; + const upgradeLoginButtonClass = upgradeLoginEnabled ? 'border-readable-green bg-readable-green w-full cursor-pointer rounded border px-3 py-2 text-sm font-semibold text-white hover:bg-readable-green/90' : 'w-full cursor-not-allowed rounded border border-gray-600 bg-gray-700 px-3 py-2 text-sm font-medium text-gray-500'; const selectedRoomLeaveLoading = selectedRoomId != null && leaveLoadingRoomIds.has(selectedRoomId); - const loginError = + const loginErrorRaw = selectedRoomId != null ? (loginErrorsByRoom.get(selectedRoomId) ?? null) : null; - const leaveError = + const loginError = loginErrorRaw != null ? translateMeshcoreUserMessage(t, loginErrorRaw) : null; + const leaveErrorRaw = selectedRoomId != null ? (leaveErrorsByRoom.get(selectedRoomId) ?? null) : null; + const leaveError = leaveErrorRaw != null ? translateMeshcoreUserMessage(t, leaveErrorRaw) : null; + const autoLoginFailureRaw = + selectedRoomId != null ? getMeshcoreRoomAutoLoginFailure(selectedRoomId) : null; + const autoLoginFailureDisplay = + autoLoginFailureRaw != null ? translateMeshcoreUserMessage(t, autoLoginFailureRaw) : null; const canPost = selectedRoomId != null && meshcoreRoomCanPost(selectedRoomId); const sessionRole = selectedRoomId != null ? meshcoreGetRoomSession(selectedRoomId)?.role : null; const selectedRoomSecretsSummary = @@ -1473,6 +1484,8 @@ export default function RoomsPanel({ const isLoggingIn = isRoomLoginInProgress(room.node_id) && !isLogged; const isLeaving = leaveLoadingRoomIds.has(room.node_id); const autoLoginFailed = getMeshcoreRoomAutoLoginFailure(room.node_id); + const autoLoginFailedDisplay = + autoLoginFailed != null ? translateMeshcoreUserMessage(t, autoLoginFailed) : ''; const showAutoLoginFailed = Boolean(autoLoginFailed) && !isLogged && !isLoggingIn && !isLeaving; const marker = resolveMeshcoreRoomSidebarMarker({ @@ -1485,7 +1498,7 @@ export default function RoomsPanel({ : isLeaving ? t('roomsPanel.leaveRoomInProgress') : showAutoLoginFailed - ? t('roomsPanel.autoLoginFailed', { error: autoLoginFailed ?? '' }) + ? t('roomsPanel.autoLoginFailed', { error: autoLoginFailedDisplay }) : hasSaved ? t('roomsPanel.legendSavedTooltip') : t('roomsPanel.legendNotSavedTooltip'); @@ -1592,7 +1605,9 @@ export default function RoomsPanel({ aria-hidden={!showAutoLoginFailed} aria-label={ showAutoLoginFailed - ? t('roomsPanel.autoLoginFailedAria', { error: autoLoginFailed }) + ? t('roomsPanel.autoLoginFailedAria', { + error: autoLoginFailedDisplay, + }) : undefined } title={markerTitle} @@ -1738,8 +1753,8 @@ export default function RoomsPanel({ @@ -1752,10 +1767,10 @@ export default function RoomsPanel({ {t('roomsPanel.continueReadOnly')} {loginError &&

{loginError}

} - {getMeshcoreRoomAutoLoginFailure(selectedRoomId) && !loginError && ( + {autoLoginFailureDisplay && !loginError && (

{t('roomsPanel.autoLoginFailed', { - error: getMeshcoreRoomAutoLoginFailure(selectedRoomId), + error: autoLoginFailureDisplay, })}

)} @@ -2657,8 +2672,8 @@ export default function RoomsPanel({