Commit Graph

1574 Commits

Author SHA1 Message Date
Dan Ditomaso
f67b8b898e fix(sdk/nodes): seed self-node stub from MyNodeInfo
If a session's config bundle doesn't include a NodeInfo for the device
itself (fresh flash, cleared nodeDB, some firmware builds), the web
sidebar's `useMyNodeAsProto()` selector — which does
`nodes.list.find(n => n.num === myNodeNum)` — never resolves and the
"loading" spinner sticks indefinitely after the overlay closes.

NodesClient now subscribes to onMyNodeInfo. If no entry exists for the
incoming myNodeNum, it seeds a stub Node with just `num` set; subsequent
NodeInfo / UserPacket / PositionPacket events patch real data on top.
Guards against myNodeNum === 0 and against overwriting an existing entry.

DeviceInfoPanel already wraps user-dependent rendering in `{user && …}`,
so no app-layer change is needed — the panel quietly omits the avatar
block until a real UserPacket arrives.

Tests cover: stub seeded when only MyNodeInfo arrives, real NodeInfo wins
over a later MyNodeInfo, real NodeInfo patches over an existing stub, and
myNodeNum=0 is ignored.
2026-05-27 22:29:40 -04:00
Dan Ditomaso
bf5648cc02 feat(web): connected affirmation in overlay, drop success toasts
ConnectingOverlay now holds a brief (1.8s) "Connected" state when a
tracked connection flips to configured: hero badge swaps to green +
CheckCircle2, radial pings stop, all chips snap to done. Then dismisses.

Removed the "Connected to X" toast firings from the Connections page
(onConnect / onRetry success branches) since the overlay is now the
canonical place for connect-flow feedback. Failure toasts stay — overlay
vanishes on error and the user still needs a signal.
2026-05-27 22:29:27 -04:00
Dan Ditomaso
5195ed9777 docs(sdk): expand React + shim sections in README
- "Using this with React" now explains the direct subscribe/value path,
  what sdk-react adds on top (providers, hook surface, escape hatches),
  and why the React bindings stay in their own package.
- New "Upgrading from @meshtastic/core" section flags the shim as a
  migration bridge, anchors the warning to the onRebooted event
  landing on client.events (not the shim), and tells readers not to
  build anything new on top of src/shim/.
2026-05-26 22:28:53 -04:00
Dan Ditomaso
1d83da36f0 docs(sdk,sdk-react): expand README for npm publish
Both packages publish to npm with `files: [..., "README.md", ...]` so the
README is the landing page. Old versions were one-line stubs.

- @meshtastic/sdk: explain feature-client surface, subpath exports
  (/transport, /protobuf, /testing), the fake transport for tests, and
  the migration story from @meshtastic/core (one SDK, separate transport
  packages, framework-agnostic core).
- @meshtastic/sdk-react: single-client + registry quickstarts, grouped
  hook reference, and why the React bindings split out of core.

Shim note carried forward in the sdk README: legacyMeshDevice export
stays until the web client finishes migrating; new code should target
MeshClient directly.
2026-05-26 22:25:41 -04:00
Dan Ditomaso
f4f9f55f34 handle device reboots 2026-05-26 22:15:05 -04:00
Dan Ditomaso
feb06e7507 adding a claude to repo 2026-05-26 22:08:34 -04:00
Dan Ditomaso
4fe0d1e25f fix(sdk/chat): append outbound message before awaiting send (instant render)
Previously the optimistic append happened after `await sendText(...)`,
which itself awaited `queue.wait(id)` — i.e. the firmware ack. On LoRa
that's 1–3 seconds, so the user saw a noticeable delay between hitting
Enter and the bubble appearing.

Move the append before the await. To do that the chat slice needs to
know the packet id synchronously, so:

- `MeshClient.sendPacket` gains an optional `packetId` parameter
  (defaults to `generatePacketId()`, behaviour unchanged for callers
  that don't pass one).
- `SendTextInput.packetId` plumbs through `SendTextUseCase` to
  `sendPacket`.
- `ChatClient.send` generates the id up-front, appends the message
  with `state=Pending` immediately, persists it, then awaits
  `sendText(..., { packetId })`. On Err the optimistic message stays
  visible but flips to `Failed` so the user sees what happened.
  Routing-ack subscriber still flips `Pending → Ack` keyed by id —
  unchanged.

Tests added (suite 74 → 76):
- "appends the optimistic message before the send promise resolves"
  — asserts the bucket contains the message synchronously after the
  send call returns its pending promise.
- "flips outbound state to Failed when sendText returns Err"
  — exercises the empty-text validation path.
2026-05-05 22:14:34 -04:00
Dan Ditomaso
14173651d5 fix(sdk/chat): optimistic-append outbound text so it shows up after send
Reproed in MessagesPage on /messages/broadcast/0: typing a message and
pressing Enter cleared the input, but the message never rendered.

Root cause:
- `ChatClient.send` calls `sendText` → `MeshClient.sendPacket(...,
  echoResponse=true)`. The echo branch dispatches `onMeshPacket` only.
- `ChatClient` subscribes to the per-portnum `onMessagePacket`, not the
  raw `onMeshPacket`. So the outbound message is never appended to its
  conversation bucket.
- The Meshtastic firmware does not loop the user's own outbound text
  back via `fromradio`, so there's no inbound echo to fall back on.

Fix:
- `ChatClient.send` now optimistically appends the outbound message
  (state=Pending) to the matching channel/direct bucket and persists
  it via the configured repository as soon as `sendText` resolves Ok.
- The existing `onRoutingPacket` subscriber flips state Pending→Ack /
  Failed when the routing ack arrives keyed by packet id — unchanged.
- `ChatStore.hasMessage(key, id)` added; the inbound `onMessagePacket`
  subscriber and the `send()` path both consult it so a
  belt-and-suspenders firmware echo doesn't double-append.

Tests: ChatClient.send.test.ts — 4 cases (broadcast append, direct
append, echo dedup, routing-ack flips state). Suite 70 → 74 / web
suite untouched at 236.
2026-05-05 21:30:30 -04:00
Dan Ditomaso
e0909e3cda style(connecting-overlay): drop hardcoded slate/sky for the app's theme tokens
Reuses the same `--color-text-primary`, `--color-text-secondary`,
`--color-background-primary`, and `--color-link` tokens the rest of
the web UI already binds to (light + dark variants resolve via
`[data-theme=...]`), so the overlay now picks up the active theme
instead of fighting it with a hardcoded slate/sky/indigo palette.

- Hero ring + disc → `bg-link` + `bg-link/{20,30}` ping ripples.
  Icon uses `text-background-primary` so it stays legible on both
  themes (white-on-blue light, dark-on-blue dark).
- Title / phase / hint copy → `text-text-primary` / `text-text-secondary`.
- Stat chips use `green-500` (matches `Button.success`) for the done
  state and `text-secondary`-derived neutrals for idle.
- Cancel button reverts to the default `Button` outline variant — was
  carrying its own slate overrides, no need.
2026-05-03 20:45:43 -04:00
Dan Ditomaso
c4173c5a5d fix(web/connections): close configured-transition race when configCompleteId is buffered
Reproed on serial reconnect: the device kept emitting from a prior
session, so the new MeshClient's `transport.fromDevice → decodePacket`
pump processed the buffered `configCompleteId` synchronously inside
the `buildMeshDevice` await — before `setupMeshDevice` had a chance to
subscribe to `onConfigComplete`. SimpleEventDispatcher doesn't replay
to late subscribers, so the event was lost. The connect overlay (which
keys off the saved-connection status) stayed visible forever even
though the rest of the app saw nodes / messages flowing.

Fix: wire two parallel paths to a shared idempotent `markConfigured`
handler:

- `events.onConfigComplete` (fast path, fires on the next live event).
- `MeshClient.device.status` signal — `decodePacket` also flips the
  signal to `DeviceConfigured` on the same packet, and signal
  subscribers don't drop the current value if it's already there.

Plus an initial synchronous check in case the status signal had
already settled before either subscribe ran. `configSubscriptions`
unsubscribes both on teardown.
2026-05-02 22:11:01 -04:00
Dan Ditomaso
c508711c0a fix(web/connecting-overlay): clear stale in-flight statuses + Cancel escape
Two reasons the overlay could "never disappear":

1. Persisted state from a prior session.
   `savedConnections` is Zustand-persisted, so a prior tab that died
   mid-connect (page reload, hot reload, force-quit) leaves a saved
   connection at status "connecting" / "configuring" forever. On cold
   boot the overlay reads that and stays visible. `onRehydrateStorage`
   now sweeps any persisted "connecting" / "configuring" /
   "disconnecting" entries back to "disconnected" — no live JS code
   could complete those, so they're stale by definition.

2. Live attempt that genuinely hangs (firmware in CLI / bootloader,
   framing out of sync, `onConfigComplete` never arrives).
   After STUCK_THRESHOLD_MS (15s) the overlay surfaces a Cancel button
   + a hint. Cancel calls `useConnections().disconnect(id)` which tears
   down the transport and flips status off "configuring". Resets if a
   new attempt starts.

i18n adds `connections.overlay.stuckHint` + `cancel`.
2026-05-02 21:51:05 -04:00
Dan Ditomaso
524139705e refactor(web): redesign ConnectingOverlay as hero card + fix early visibility
Two changes:

Visibility fix — overlay now reads `savedConnections` for any entry
whose status is "connecting" or "configuring". The previous code keyed
off `device.connectionPhase` from the active deviceStore entry, but
that entry doesn't exist until partway through `setupMeshDevice` —
the overlay was hidden during transport-open + storage-open (often
the slowest part of the connect flow).

Visual redo (option C — hero card with radial pings):
- Center: transport-type icon (Globe / Bluetooth / Cable) on a
  gradient disc with two concentric ping rings.
- Below: device name + phase label with small spinner.
- 2x2 grid of stat chips (Identity / Metadata / Channels / Nodes)
  that flip from gray (dashed circle) to emerald (check or count)
  as the SDK reports each piece arriving.
- Slate-to-black gradient panel; non-dismissable while active.
- i18n strings under `connections.overlay.*` updated to match.
2026-05-02 21:44:28 -04:00
Dan Ditomaso
f0eaf4da8b feat(sdk,web): live connect-progress overlay (terminal-style log)
Surfaces what the connection handshake is doing in real time. Per DDD,
the predicate + counters live in the SDK; the web overlay is a thin
consumer of the signal.

SDK
- New `MeshClient.progress: ReadonlySignal<ConnectionProgress>` with the
  state machine `idle → configuring → configured`. Counters
  (config / modules / channels / nodes + boolean myInfo / metadata)
  tally inbound packets each `configure()` cycle.
- `configure()` resets to `configuring` with empty counters.
  `onConfigComplete` flips to `configured`.
- `ConnectionProgress` + `ConnectionProgressCounters` exported from the
  package surface.
- 6 new tests in `MeshClient.progress.test.ts`.

sdk-react
- `useConnectionProgress()` hook reads the active client's signal,
  returns `{ phase: "idle" }` when there is no active client so the
  caller can render unconditionally.

apps/web
- `ConnectingOverlay` redesigned as a terminal-style streaming log:
  - macOS-window-style header (red/yellow/green dots) with a live
    `phase` indicator pulse.
  - Monospace event lines with `→` (active) / `✓` (done) / `•` (info)
    glyphs and a relative-seconds timestamp column.
  - Auto-scrolls to the latest entry; blinking caret on the trailing
    active line.
  - Lines derive from `device.connectionPhase` transitions and per-
    counter bumps on `MeshClient.progress`. Resets on each fresh
    connect cycle.
- Mounted at the top of the device tree (outside the device-conditional
  branch) so it shows during a first-time connect from the Connections
  screen as well as reconnects from inside the app.
- i18n strings under `connections.overlay.log.*`.
2026-05-02 08:26:18 -04:00
Dan Ditomaso
523ee60b71 chore(web/storage): logs + 5s timeout around sqlocal DB open
The connect path silently hung at `buildMeshDevice` when sqlocal failed
to open. Adding visibility:

- `getStorageDb` logs creation start, success (with elapsed ms), and
  failure (with name + message).
- `buildMeshDevice` races the DB open against a 5s timeout. If sqlocal
  hangs (stale Web Lock from a crashed prior tab, OPFS contention,
  worker hang), the connect path falls through to the SDK's in-memory
  repositories instead of blocking forever — the user gets a working
  session and the warn-level log surfaces the underlying cause.
- `buildMeshDevice` logs entry, DB-ready elapsed, repos-opened, and
  fall-through decisions.
2026-04-28 20:59:57 -04:00
Dan Ditomaso
2984488c85 chore(logging): tslog-gated debug logs across the serial connect path
createLogger now reads `localStorage["mesh-debug"]==="1"` (browser) or
`MESH_DEBUG=1` (node) on each invocation. When set, default minLevel
drops to debug; otherwise stays at info. Existing callers
(MeshClient + co.) are unaffected.

Logs added (all `[name]`-prefixed via tslog) covering the connect
lifecycle the user just hit:

- TransportWebSerial.preparePort: enter, close-needed branch,
  close result, each open() attempt, retry decisions, final error.
- TransportWebSerial constructor: pipe wiring, toDevice pipe abort
  vs reject path.
- TransportWebSerial read loop: start, done, throw (with
  closingByUser flag), reader-lock release.
- TransportWebSerial.disconnect: each cleanup step (abort, pipe
  settled, fromDevice cancel, connection.close).
- transports.ts openSerial: cached-port flag, getPorts count,
  picker invocation, resolved-port stream state, createFromPort
  Result outcome (kind + userMessage on Err).
- useConnections.connect / setupMeshDevice / teardown: phase
  transitions, configure() resolve / reject, onConfigComplete fire,
  BT gattserverdisconnected, transport.disconnect rejection.

Flip on:
  localStorage.setItem("mesh-debug", "1"); location.reload();
2026-04-27 22:06:55 -04:00
Dan Ditomaso
97c41046da refactor(transport-web-serial): own port-state hygiene + Result-typed connect errors
Pre-flight close + open-with-backoff lived in apps/web's transport
orchestrator — wrong layer per DDD. Lifted into TransportWebSerial
itself.

Changes:
- `TransportWebSerial.createFromPort` (and `create`) now return
  `Result<TransportWebSerial, SerialConnectError>`. No throwing on
  expected failures; callers branch on the Result.
- New `preparePort` private static that:
  * force-closes the port if `readable` / `writable` are non-null
  * opens with up to 4 attempts (250 / 500 / 750 ms backoff) on
    `InvalidStateError` — common during USB re-enumeration
  * tags persistent failures `kind: "in-use"` (other tab / app
    holding the port) vs `"busy"` (transient / unknown) vs
    `"unavailable"` (no streams after open)
- `SerialConnectError` carries `kind` + `userMessage` ready for UI.
- `apps/web/src/core/connections/transports.ts` drops its old
  conditional `port.close()` + 100ms sleep; just calls
  `createFromPort` and unwraps the Result. `SerialConnectError` is
  re-exported alongside `BluetoothConnectError` so the Connections
  page can pattern-match.
- `AnyTransport` definition simplified — was deriving via
  `Awaited<ReturnType<...>>` which broke once `createFromPort`
  returned a Result.
- Test FakeSerialPort updated to mirror the real spec: `readable`
  /`writable` go null on `close()` and re-create on `open()`.
- 3 new port-hygiene tests cover the close-on-prior-open path,
  the backoff-retry success path, and the persistent-`in-use`
  Err path. Suite: 5 → 8.

Matches the better-result preference for new application/use-case
code. BT transport stays on the throwing `BluetoothConnectError`
shape for now — separate refactor if we want to converge.
2026-04-27 20:56:54 -04:00
Dan Ditomaso
dad544b47b feat(sdk,web): detect newly-flashed device + prompt region setup
Mirrors Meshtastic-Android's `regionUnset` flow. A freshly-flashed device
boots with `Config.LoRa.region == UNSET` and won't transmit at all until
the user picks one. Web now surfaces this:

- `ConfigClient.isRegionUnset: ReadonlySignal<boolean>` — computed from
  the radio config signal, false until the first LoRa packet arrives so
  consumers don't flash the prompt during the connect handshake.
- `useIsRegionUnset()` hook in sdk-react, re-exported from the package's
  public surface.
- `RegionSetupReminder` mounts inside the connected-device branch of
  App.tsx. While the SDK reports `isRegionUnset == true`, a persistent
  toast offers a "Set region" CTA that deep-links into the LoRa tab
  (`/settings/radio`). Toast auto-dismisses the moment a real region is
  committed.

Predicate matches Android exactly (single check, no owner/name/setupComplete
heuristics). Lives in the SDK config slice — UI is a thin consumer.

3 new SDK tests; SDK 64 / sdk-react 8 / web 236.
2026-04-27 20:16:25 -04:00
Dan Ditomaso
cc7d2c7078 refactor(transport-web-bluetooth): own GATT connect-retry semantics + typed error
Per the DDD layering — transport-level concerns belong in the transport
package, not the web app's connection orchestrator. Moved the GATT
`NetworkError: Connection attempt failed` retry-once + the user-facing
error wrapping out of `apps/web/src/core/connections/transports.ts` and
into `TransportWebBluetooth.prepareConnection`.

New `BluetoothConnectError` carries:
- `kind: "transient" | "unavailable" | "missing-service"` so callers can
  decide whether to suggest retry vs re-pair vs firmware upgrade.
- `userMessage` — human-readable, actionable string ready for UI.

The web app's `transports.ts` is back to a thin wrapper that just calls
`TransportWebBluetooth.createFromDevice` and surfaces whatever error it
throws. `BluetoothConnectError` is re-exported through the connections
module so the Connections page can `instanceof`-check without depending
on the transport package directly.
2026-04-27 20:04:38 -04:00
Dan Ditomaso
73eabf33c8 fix(web/connections): retry-once + clearer error on Bluetooth GATT connect failure
`NetworkError: Connection attempt failed` from `device.gatt.connect()` is the
most common transient when reconnecting BT — OS stack hiccup, device just
woke from sleep, etc. Adding one retry with a 750ms delay clears most of
those. Persistent failures get a wrapped error message naming the actual
likely causes (out of range, powered off, paired with phone or another
tab) instead of leaving the raw DOMException for the user to interpret.
2026-04-27 20:02:20 -04:00
Dan Ditomaso
c160df5f2d fix(web): clear pre-existing typecheck errors (36 → 0)
Sweep covering several unrelated cohorts:

- Delete dead `NewDeviceDialog.tsx` (commented-out in App.tsx, imports point at
  non-existent `@components/PageComponents/Connect/*`).
- Add `better-result` to sdk-react deps so `useChat`/`useDirectChat`/
  `useFavoriteNode`/`useIgnoreNode` resolve.
- Fix `test-utils.tsx`: route at `routeTree.gen.ts` was never generated;
  re-export the existing `routeTree` constant from `routes.tsx` and pass
  the missing router context + DeviceWrapper deviceId.
- Pre-existing `noUncheckedIndexedAccess` violations in `pskSchema.test.ts`,
  `x25519.ts`, `Table/index.tsx` + test, `useCopyToClipboard` (Timeout vs
  number) — non-null asserts where the index is guaranteed by the
  surrounding code, plus a defensive `if (!cell) return 0` in the table sort.
- Immer `WritableDraft<T>` mismatches against SDK's `MeshDevice`/`Device`
  classes — cast through `Draft<X>` where the inferred recursive draft type
  can't represent SDK class internals.
- `deviceStore.mock` updated for the `connectionPhase`/`connectionId` +
  setters added when the connection lifecycle was extracted.
- `HeatmapLayer` accepts the `isVisible` prop everyone else's layer accepts.
- `SNRTooltipProps.to` widened to `string | undefined` (single-node hover
  has no `to`).
- Zod resolver returns `Record<string, never>` for the error-path values
  shape that react-hook-form expects.
- Mock `NodeInfo` in `useFilterNode.test.ts` gains the `isMuted` proto
  field that landed upstream.
2026-04-27 20:00:36 -04:00
Dan Ditomaso
56c983c3d0 chore(build): tighten tsdown perf flags across SDK + transport packages
- Drop bogus `dts.isolatedDeclarations` field from SDK (not a real tsdown
  option; tsdown was silently ignoring it).
- Make every package explicit about the perf-relevant flags rather than
  relying on defaults that drift between tsdown versions: target=esnext
  (no syntax downleveling), sourcemap=false, minify=false, treeshake=true,
  report=false, splitting=false.
- Modest wall-clock wins: SDK 1020→752ms, transports ~720→~600ms.

`isolatedDeclarations: true` in tsconfig would unlock the bigger oxc-dts
fast path but the codebase isn't ID-clean yet — left as future work.
2026-04-26 21:19:52 -04:00
Dan Ditomaso
ac7fc77435 fix(sdk): build clean dts for npm consumers; transports drop Types/Utils namespace
Previously transport-* package dts builds failed with "Missing export" because
rolldown's dts bundler couldn't follow tsdown's cross-chunk re-exports out of
@meshtastic/sdk. Three things land here:

1. mod.ts now re-exports `fromDeviceStream`, `toDeviceStream`, `Queue`, and
   `Xmodem` directly. Transport packages were importing these via the legacy
   `Utils` namespace; namespace re-exports are exactly what rolldown chokes
   on. Transport packages (http, deno, node, node-serial, web-bluetooth,
   web-serial) and shared transportContract.ts now use direct imports —
   `import { Transport, DeviceOutput, ... } from "@meshtastic/sdk"`.

2. package.json `exports` split: `types` points at `./dist/<entry>.d.ts`
   (built dts) so tsdown's downstream rolldown reads a self-contained file;
   `default` keeps source paths so vitest + workspace-internal imports still
   resolve to .ts source.

3. Postbuild `rename-dts.mjs` now (a) inlines internal chunk dts files
   (Transport-<hash>.d.ts) into each entry's dts, rewriting the letter
   aliases tsdown produces; (b) strips `type` modifiers from re-exports
   so rolldown accepts the bindings as both value- and type-shape;
   (c) declares the synthetic `Types`/`Utils` namespaces explicitly,
   replacing the broken `<wrapper>_d_exports as Types` aliases tsdown
   emits for `export * as` patterns.

vitest.config.ts gains `apps/*` so the workspace move keeps test discovery
working for the apps/web project.
2026-04-26 19:14:56 -04:00
Dan Ditomaso
9219b8a207 feat(web): Position "Use browser location" button + Telemetry firmware capability gate
Two backlog UX nits.

DynamicForm:
- FormGroup gains an optional `footer?: ReactNode` slot that renders
  after the field list. Lets pages drop arbitrary JSX (action buttons,
  notes, etc.) into a card without subclassing the form.

Position:
- New `UseBrowserLocationButton` component lives inside the Device GPS
  card via the new `footer` slot. Calls navigator.geolocation, writes
  lat/lng/altitude into the form via `useFormContext` (the
  PositionValidation form is wrapped in FormProvider by DynamicForm).
- Truncates lat/lng to 7 decimals to match the proto's max precision.
- Gracefully no-ops when navigator.geolocation is unavailable
  (insecure context).
- Failed reads surface as a toast carrying the GeolocationPositionError
  message; busy state disables the button + flips the label.
- New i18n keys: position.useBrowserLocation.{label,busy,failed}.

Telemetry:
- `device_telemetry_enabled` is only writable on firmware ≥ v2.7.12
  (mirrors Android's `Capabilities.canToggleTelemetryEnabled`). Adds a
  small `firmwareAtLeast` semver helper and reads
  `device.metadata.get(0)?.firmwareVersion`. The toggle is hidden on
  older firmware so we don't push a value the device will silently
  drop.
- Unparseable / unknown firmware versions default to "show the toggle"
  (rather than hide), so we don't accidentally hide the control on
  devices we can't classify.

Build clean, 236 web tests still pass.
2026-04-26 18:54:27 -04:00
Dan Ditomaso
a7b1923ca2 fix(web/connections): probe = "online" not "configured"; flip status to "configuring" before DB open; await transport disconnect
Three reconnect-flow bugs:

1. probeConnection returned "configured" for serial / bluetooth when
   the browser had stored permission for the device — but permission
   ≠ a configured Meshtastic device. The card showed "connected"
   and a Disconnect button before the user had ever clicked Connect.
   Probe now returns "online" (= "available, click to connect"),
   matching the HTTP path. "configured" is reserved for the
   onConfigComplete callback.

2. setupMeshDevice flipped status from "connecting" → "configuring"
   AFTER awaiting buildMeshDevice, which opens the OPFS DB. If the
   DB open stalled (multi-tab contention, slow first-time init), the
   card sat at "connecting" indefinitely. Move the status flip ahead
   of the persistence await — UI shows "configuring" while
   persistence is spinning up.

3. teardown's `device?.connection?.disconnect()` returned a Promise
   that was never awaited, so the underlying transport's port.close()
   could race the next port.open() on a fast disconnect → reconnect.
   Make teardown async, await disconnect, propagate via async
   removeConnection / disconnect callers.

Plus: connect() catch block logs the underlying error before turning
it into a status update, so the actual failure shows up in console
even when the toast text is generic.
2026-04-26 18:44:16 -04:00
Dan Ditomaso
f899c97ac8 feat: PR #12 Phase C — delete packages/core, retarget transports to @meshtastic/sdk, bump majors
The legacy `@meshtastic/core` package is gone. The six transport-*
packages and the web app no longer depend on it; everything routes
through `@meshtastic/sdk`.

Transport packages (transport-deno, transport-http, transport-node,
transport-node-serial, transport-web-bluetooth, transport-web-serial):
- package.json deps swap `@meshtastic/core: workspace:*` for
  `@meshtastic/sdk: workspace:*`.
- src/transport.ts + src/transport.test.ts imports point at
  `@meshtastic/sdk` (the `Types` and `Utils` namespaces are still
  exported from the SDK so source code is otherwise unchanged).
- README.md examples updated to import from `@meshtastic/sdk`.

apps/web:
- Drops the leftover `@meshtastic/core` workspace dep; nothing in
  apps/web/src imports from it.

@meshtastic/sdk:
- README description loses the "Replaces @meshtastic/core" suffix —
  there's nothing left to replace.
- Three legacy shim files keep their re-exports but their docstrings
  drop the "Phase-A shim, removed in Phase C" framing. The MeshDevice
  facade survives because the web app's `connection.factoryResetDevice()`
  / `connection.reboot()` / etc. callsites still go through it; new
  consumers should reach into `client.config` / `client.chat` / etc.
- New scripts/rename-dts.mjs is a postbuild step that renames tsdown's
  hashed entry-point dts outputs (`mod-<hash>.d.ts` etc.) back to their
  canonical names so package.json `types` and downstream dts-bundlers
  can find them. Internal chunk dts files (e.g. `Transport-<hash>.d.ts`)
  are intentionally NOT renamed because mod.d.ts imports them by path.
- build:npm runs tsdown then the rename script.

Version bumps (signal: now require @meshtastic/sdk):
- @meshtastic/sdk             0.1.0 -> 1.0.0
- @meshtastic/sdk-react       0.1.0 -> 1.0.0
- @meshtastic/sdk-storage-sqlocal 0.1.0 -> 1.0.0
- @meshtastic/transport-http       0.2.5 -> 1.0.0
- @meshtastic/transport-web-serial 0.2.5 -> 1.0.0
- @meshtastic/transport-web-bluetooth 0.1.5 -> 1.0.0
- @meshtastic/transport-deno       0.1.1 -> 1.0.0
- @meshtastic/transport-node       0.0.2 -> 1.0.0
- @meshtastic/transport-node-serial 0.0.2 -> 1.0.0

Workflows: nothing referenced packages/core directly (only the
release-packages.yml glob which already collapsed in the apps/web
move). README packages table loses the legacy `packages/core` row.

Verified: web build clean, all four package suites green
(web 236, sdk 65, sdk-react 8, storage 30).

The transport packages' dist build is currently blocked by a tsdown
cross-chunk dts resolution glitch where the `Types` namespace import
can't follow `mod.d.ts`'s reference to the internal `Transport-<hash>`
chunk. Runtime is fine — only the published-types path is affected,
and transport packages publish via their own release flow. Logged as
follow-up.
2026-04-26 12:16:57 -04:00
Dan Ditomaso
daf7c22b60 chore: move packages/web → apps/web
Web is a deployable SPA, not a library — no @meshtastic scope, no
exports map, no npm/JSR publish target, no other workspace member
depends on it. Conventional pnpm/Turbo/Nx layout puts deployables
under apps/ and libraries under packages/. Doing the move now (after
PR #9 / #10 / unread / useConnections refactor land but before Phase
C ships) so the lib-only packages/* directory remains stable as we
delete packages/core.

Mechanics:
- pnpm-workspace.yaml: add `apps/*` to packages.
- git mv packages/web apps/web (entire directory tree).
- vite.config.ts uses process.cwd(), so no internal path edits needed.
- pnpm filter commands still resolve by package name (`pnpm --filter
  meshtastic-web run build`) — no script changes.

External-path consumers updated:
- README.md table row.
- tsconfig.json reference.
- 7 GH workflows (pr.yml, nightly.yml, release-web.yml, the three
  crowdin-*.yml, release-packages.yml). The
  packages/release-packages.yml `packages/* | grep -v packages/web`
  filter is now redundant since web isn't in packages/* anymore — it
  collapses to plain `ls -d packages/*`.

Verified: web build clean, web 236 / sdk 65 / sdk-react 8 / storage
30 tests all pass.
2026-04-26 11:10:23 -04:00
Dan Ditomaso
d4cd649b6b refactor(web): split useConnections into focused modules
useConnections went from 661 lines down to 264 by extracting three
single-responsibility helpers under packages/web/src/core/connections/.
The hook is now pure orchestration; transport / heartbeat / SDK-client
/ status-probe concerns live in their own files.

New modules:
- core/connections/heartbeat.ts (45 LOC): startConfigHeartbeat (5s),
  startMaintenanceHeartbeat (5min), stopHeartbeat. Owns the heartbeats
  Map; replaces the inline interval bookkeeping. Both helpers stop the
  prior heartbeat first so callers don't have to.
- core/connections/sdkClient.ts (61 LOC): buildMeshDevice(connId,
  deviceId, transport) opens the OPFS DB, builds the four sqlocal
  repositories (chat / draft / nodes / telemetry), and constructs the
  MeshDevice with the canonical retention defaults (chat 90d / 1k per
  bucket; telemetry 30d / 500 per node). Falls back to in-memory when
  sqlocal is unavailable.
- core/connections/transports.ts (236 LOC): per-transport openTransport
  factory (HTTP reachability check, BT permission re-acquisition with
  optional prompt, Serial port lookup with close-then-reopen),
  probeConnection for refreshStatuses, closeTransport for cleanup.
  Discriminates over conn.type so useConnections doesn't carry the
  switch logic.

useConnections (now 264 LOC):
- Owns the cachedTransports + configSubscriptions maps and the
  Zustand selectors.
- teardown(id, conn) consolidates the heartbeat + config-sub +
  meshDevice.disconnect + transport-close cleanup that used to be
  duplicated across removeConnection / disconnect.
- connect calls openTransport and forwards the resulting transport +
  cached BT/Serial handle into setupMeshDevice. The BT
  gattserverdisconnected listener is wired here (it needs the connId).
- refreshStatuses simplifies to a filter + Promise.all over
  probeConnection.
- syncConnectionStatuses unchanged.

Behavior is unchanged. No new tests — the existing web suite (236)
still passes; build clean.
2026-04-26 10:54:11 -04:00
Dan Ditomaso
eef69dbacb feat(sdk,sdk-react,web): unread counts on the SDK ChatClient
ChatClient gains a `chat.unread` namespace mirroring `chat.drafts`:
- byKey: ReadonlySignal<Map<conversation-key-string, number>>
- total: ReadonlySignal<number>
- count(key): ReadonlySignal<number>
- markRead(key): void

Increments happen in the existing onMessagePacket subscriber when
packet.from !== client.myNodeNum (so the outbound echo of our own send
doesn't bump anything). Uses the same ConversationKey shape the rest
of the chat slice already keys by, so direct messages keyed by peer
and broadcasts by channel can never collide.

Counts are in-memory only — persistence will come later if needed
(would require tracking a lastReadAt timestamp per conversation in the
message repository and computing unread on hydrate).

sdk-react: new useTotalUnread / useUnreadCount(key) / useUnreadByKey
hooks. All fall back to safe empty values when no client is active.

Web migration:
- Sidebar reads useTotalUnread instead of summing
  deviceStore.unreadCounts.
- MessagesPage: per-channel SidebarButton counts read from
  useUnreadByKey()'s `channel:N` keys; per-direct-message counts from
  `direct:N` keys. Click handlers call meshClient.chat.unread.markRead
  with the typed ConversationKey instead of resetUnread.
- subscriptions.ts: drops onMessagePacket -> incrementUnread mirror
  (SDK now owns it). myNodeNum local goes away too — nothing else
  consumed it.
- deviceStore: removes unreadCounts field + incrementUnread /
  resetUnread / getUnreadCount / getAllUnreadCount methods. The mock
  + test block in deviceStore.test.ts also drop their unread coverage.
- deviceStore.mock.ts: also strips the dead changeRegistry / setChange
  / hasConfigChange / etc. methods that survived from the earlier
  changeRegistry deletion.

Tests: SDK 61 -> 65 (+4 ChatClient.unread.test), sdk-react 8, web 236.
Build clean.
2026-04-26 10:42:05 -04:00
Dan Ditomaso
273bcf6efd feat(sdk,sdk-storage-sqlocal,web): TelemetryRepository port + SqlocalTelemetryRepository (PR #10)
Telemetry slice gains the same persistence shape as chat / nodes: a
repository port on the SDK side, an in-memory default, an OPFS-backed
SQLite adapter on the storage package side, and lazy hydration into the
in-memory store.

SDK (@meshtastic/sdk):
- New TelemetryRepository port (loadRecent, loadBefore, append,
  appendBatch, prune, clearNode, clear) + TelemetryRetentionPolicy
  (maxPerNode, olderThanMs).
- New InMemoryTelemetryRepository — default when no adapter is wired.
- TelemetryClient grows TelemetryClientOptions { repository?, retention? }.
  Each incoming onTelemetryPacket appends to both the in-memory store
  and the repository, then the configured retention policy prunes the
  repository.
- latest(nodeNum) and history(nodeNum) lazy-hydrate the in-memory store
  from the repository on first subscribe (HYDRATE_LIMIT = 256). loadBefore
  passes through to the repository for paged reads.
- clearNode / clear methods exposed on the client.
- MeshClient gains options.telemetry which is passed through to
  TelemetryClient.

@meshtastic/sdk-storage-sqlocal:
- New SqlocalTelemetryRepository under ./telemetry subpath. Drizzle-typed
  insert/select/delete against the existing telemetry table; payload is
  stored as base64 of the proto bytes (per-kind schema lookup) so the
  wire shape is the source of truth across schema additions.
- prune({ maxPerNode }) trims with a per-node offset query. prune({
  olderThanMs }) deletes by ts cutoff.
- Six new tests cover round-trip, ascending order, deviceId scoping,
  per-node retention, age-based prune, and proto payload preservation.
- Re-exports added to mod.ts + ./telemetry subpath in package.json.

web:
- useConnections wires SqlocalTelemetryRepository per connection with
  retention { maxPerNode: 500, olderThanMs: 30 days }.

Tests: SDK 57 (was 49 — +8 new), storage 30 (was 24 — +6), web 237.
Build clean.
2026-04-26 10:29:00 -04:00
Dan Ditomaso
067719115f feat(sdk,web): delete changeRegistry; ConfigEditor owns all settings dirty state
Phase 3c-2 of PR #9. With every web settings page already migrated, the
deviceStore changeRegistry plumbing has no remaining writers or readers
and is deleted.

ConfigEditor (sdk):
- New queueAdminMessage(message) for one-off side flows. Queue drains
  inside commit() between beginEdit and commitEdit. Used by
  Position.tsx for setFixedPosition; future side flows (e.g. SetTime)
  reuse the same queue.
- Disconnect / commit success now also reset the admin-message queue.
- isDirty includes a queued admin-message check.

Web Settings/index.tsx:
- handleSave is now a one-liner: editor.commit(). All the legacy
  get*Changes / get*ChangeCount / clearAllChanges / connection.setConfig
  loops + the post-commit deviceStore mirror writes are gone.
- handleReset calls editor.reset() instead of clearAllChanges().
- Save-button gating reads editor.isDirty + RHF dirty.
- Section change-count badges read directly from editor.dirtyRadioSections /
  editor.dirtyModuleSections / editor.dirtyChannels lengths.

Tab dirty-dot indicators (RadioConfig.tsx / DeviceConfig.tsx /
ModuleConfig.tsx): rewired from hasConfigChange/hasModuleConfigChange/
hasChannelChange/hasUserChange to editor signals (dirtyRadioSections,
dirtyModuleSections, dirtyChannels, isOwnerDirty).

Position.tsx: queueAdminMessage call now goes through editor — pending
fixed-position coordinates ride along with the rest of the editor's
dirty state and ship under the same beginEdit/commitEdit window.

deviceStore.ts:
- Drops the entire changeRegistry surface from the Device interface and
  factory: setChange / removeChange / hasChange / getChange /
  clearAllChanges / hasConfigChange / hasModuleConfigChange /
  hasChannelChange / hasUserChange / getConfigChangeCount /
  getModuleConfigChangeCount / getChannelChangeCount /
  getAdminMessageChangeCount / getAllConfigChanges /
  getAllModuleConfigChanges / getAllChannelChanges / queueAdminMessage /
  getAllQueuedAdminMessages, plus the changeRegistry field.
- getEffectiveConfig / getEffectiveModuleConfig collapse to plain
  device.config[variant] / device.moduleConfig[variant] passthroughs
  (kept as a thin compatibility shim for residual callers; the
  changeRegistry merge is gone).
- types.ts: ValidConfigType / ValidModuleConfigType derive directly
  from LocalConfig / LocalModuleConfig keys (was imported from the
  deleted changeRegistry.ts).
- The 252-line changeRegistry.ts file is deleted; ~250 lines also drop
  out of the deviceStore factory.
- deviceStore.test.ts loses the change-registry describe block (3
  tests). 237 web tests still passing (was 240; -3 dead tests).

Net: -1066 / +136 lines.
2026-04-26 10:13:28 -04:00
Dan Ditomaso
3388d4c2df feat(sdk,web): migrate User + Channel + ImportDialog off changeRegistry; ConfigEditor gains owner support
Phase 3c-1 of PR #9. The last writers/readers of changeRegistry are
moved to the SDK ConfigEditor. (One sweep commit drops the
changeRegistry plumbing from the deviceStore.)

ConfigEditor (sdk/features/config/domain/ConfigEditor.ts):
- New owner state: baselineOwner / workingOwner signals, isOwnerDirty,
  setBaselineOwner(user), setOwner(user). The editor doesn't subscribe
  to a SDK signal for the user (owner arrives via NodeInfo, not its own
  packet), so the web seeds baseline from useMyNodeAsProto via setter.
- commit() now also runs setOwner (admin message) when isOwnerDirty,
  inside the same beginEdit/commitEdit window as radio/module/channels.
- isDirty aggregates owner dirty too. reset() reverts working owner.
- Disconnect clears baseline + working owner.

User page (Settings/User.tsx):
- Aligned to Android: single "User Config" card, fields in order:
  Node ID (read-only), Long Name, Short Name, Hardware model
  (read-only enum name), Unmessageable, Licensed amateur radio (Ham).
- onSubmit calls editor.setOwner instead of connection.setOwner —
  edits queue with the rest of the editor's pending changes and ship
  on the global Save.
- Preserves non-edited User fields (id, hwModel, role, publicKey,
  macaddr) by spreading the baseline before applying form deltas.
- isUnmessageable now reads from the proto's isUnmessagable instead of
  defaulting to false (longstanding bug — the existing value was
  ignored).
- shortName min loosened to 1 (was 2) per Android rule.

Channel page (PageComponents/Channels/Channel.tsx):
- Reads working channel from editor.channels via useSignal projection;
  drops getChange / setChange / removeChange / deepCompareConfig.
- onSubmit -> editor.setChannel(payload).

Channels page (PageComponents/Channels/Channels.tsx):
- Reads channel list from useChannels() (SDK), maps each into a real
  proto Channel via create(ChannelSchema, ...) for the per-tab Channel
  component which still expects the proto shape.
- Per-channel dirty indicator now reads editor.dirtyChannels instead
  of hasChannelChange.

ImportDialog (Dialog/ImportDialog.tsx):
- Apply path -> editor.setChannel + editor.setRadioSection("lora",...)
  instead of setChange. Channel reads via SDK.

i18n: config.json user.* gains userConfig (card label), nodeId,
hardwareModel keys; field labels reworded to match Android (e.g.
"Licensed amateur radio (Ham)", "Unmessageable" with "Unmonitored or
Infrastructure" description). UserValidationSchema gains optional
nodeId / hardwareModel display fields.
2026-04-26 10:03:43 -04:00
Dan Ditomaso
e4d0b11292 feat(web): migrate 11 module-config pages to ConfigEditor; Android-aligned labels
Phase 3b of PR #9. All remaining module pages off changeRegistry onto
the SDK ConfigEditor. Section structure + field order + labels taken
from Meshtastic-Android source of truth.

Pages migrated (single "<X> Config" card unless noted):

Serial: enabled, echo, rxd ("RX"), txd ("TX"), baud, timeout, mode,
overrideConsoleSerialPort. Drops the per-field disable-when-!enabled
gating (Android keeps fields editable while the module is off).

ExternalNotification: four cards (External Notification Config /
Notifications on message receipt / Notifications on alert/bell receipt
/ Advanced). New card layout matches Android. Schema + page now
include useI2sAsBuzzer. Per-field disable-when-!enabled gating dropped.
Card 1: enabled. Card 2: alertMessage*. Card 3: alertBell*. Card 4:
output, active, outputBuzzer, usePwm, outputVibra, outputMs,
nagTimeout, useI2sAsBuzzer.

StoreForward: adds isServer ("Server"). Removes per-field disable-on-
!enabled. Schema gains isServer.

RangeTest: relabel to Android strings ("Range test enabled", "Sender
message interval (seconds)", "Save .CSV in storage (ESP32 only)").
Removes per-field disable-on-!enabled.

Telemetry: adds deviceTelemetryEnabled ("Send Device Telemetry" — gated
on firmware ≥ v2.7.12 capability flag on Android; web shows
unconditionally for now) and powerScreenEnabled ("Power metrics on-
screen enabled"). Schema gains both. Field labels updated.

CannedMessage: relabels per Android ("Canned message enabled", "GPIO
pin for rotary encoder A/B/Press port", "Generate input event on
Press/CW/CCW", "Up/Down/Select input enabled", "Allow input source",
"Send bell"). The free-form `messages` text field is a separate admin
RPC (setCannedMessages on Android) — queued as a follow-up.

Audio: relabel ("CODEC 2 enabled", "PTT pin", "CODEC2 sample rate",
"I2S word select / data in / data out / clock").

NeighborInfo: adds transmitOverLora toggle. Schema gains it.

AmbientLighting: relabel ("Ambient Lighting Config" card). Same fields.

DetectionSensor: relabel + reorder per Android (enabled,
minimumBroadcastSecs, stateBroadcastSecs, sendBell, name, monitorPin,
detectionTriggerType, usePullup).

Paxcounter: relabel ("Paxcounter enabled", "Update interval", "WiFi
RSSI threshold (defaults to -80)", "BLE RSSI threshold").

i18n: moduleConfig.json gains card-level keys (serialConfig,
externalNotificationConfig, notificationsOnMessage, notificationsOnAlert,
advanced, storeForwardConfig, rangeTestConfig, telemetryConfig,
cannedMessageConfig, audioConfig, neighborInfoConfig,
ambientLightingConfig, detectionSensorConfig, paxcounterConfig) and
new field keys (deviceTelemetryEnabled, powerScreenEnabled,
transmitOverLora, isServer, useI2sAsBuzzer, minimumBroadcastSecs,
stateBroadcastSecs, sendBell, name, monitorPin, detectionTriggerType,
usePullup), with field labels reworked to match Android strings.xml
values.

The User module page is the last consumer of changeRegistry. After it
moves, the deviceStore changeRegistry plumbing + getEffectiveConfig +
setChange / removeChange / getAllConfigChanges / etc. can be deleted.
2026-04-26 09:00:46 -04:00
Dan Ditomaso
ba3948ef0f feat(web): migrate Bluetooth/Device/Display/Power/Network/Security pages to ConfigEditor
Phase 3a of PR #9. Six more radio-config pages off changeRegistry onto
the SDK ConfigEditor. Section structure + field order + labels taken
from Meshtastic-Android source of truth.

Bluetooth: single "Bluetooth Config" card. Drops the legacy disable-
mode-when-disabled rule; mode and PIN remain editable while bluetooth
is off (matches Android).

Device: four cards (Options / Hardware / Time Zone / GPIO).
- Options: role, rebroadcastMode, nodeInfoBroadcastSecs.
- Hardware: doubleTapAsButtonPress, disableTripleClick,
  ledHeartbeatDisabled. Web keeps the proto-aligned (un-inverted) field
  names; Android renders them inverted as "Triple Click Ad Hoc Ping" /
  "LED Heartbeat" — equivalent semantics, simpler binding.
- Time Zone: tzdef.
- GPIO: buttonGpio, buzzerGpio.
- Drops debug-only "Device Storage & UI" card (debug-only on Android).
- Drops the inline "Use phone time zone" action button (queued for a
  follow-up; needs a browser-tz implementation).

Display: two cards (Device Display / Advanced).
- Device Display: compassNorthTop ("Always point north"),
  use12hClock, headingBold, units.
- Advanced: screenOnSecs, autoScreenCarouselSecs, wakeOnTapOrMotion,
  flipScreen, displaymode, oled, compassOrientation (new — was missing
  from web).
- Drops gpsFormat (deprecated in proto; moved to DeviceUIConfig).

Power: single "Power Config" card. Order: isPowerSaving,
onBatteryShutdownAfterSecs, adcMultiplierOverride (web keeps the single
number input; Android splits into a switch + conditional float input —
deferred), waitBluetoothSecs, sdsSecs, minWakeSecs,
deviceBatteryInaAddress. Drops lsSecs (Android does not surface it).

Network: three cards (WiFi Options / Ethernet Options / Advanced).
- WiFi Options: wifiEnabled, wifiSsid, wifiPsk.
- Ethernet Options: ethEnabled.
- Advanced: ntpServer, rsyslogServer, enabledProtocols ("Enabled" /
  forward mesh over UDP), addressMode ("IPv4 mode"), then ipv4 ip /
  gateway / subnet / dns when STATIC.
- Drops the read-only "current connections" card (no analog state on
  web today). IPv4 int↔string conversion is unchanged.

Security: four cards (Direct Message Key / Admin Keys / Logs /
Administration).
- Direct Message Key: privateKey + publicKey (read-only) + Generate +
  Backup buttons.
- Admin Keys: three slots, gated on Legacy Admin channel toggle.
- Logs: serialEnabled, debugLogApiEnabled (Android order: serial
  first).
- Administration: isManaged, adminChannelEnabled.
- Byte-array conversions and the Pki regenerate / managed-mode dialogs
  are unchanged.

i18n: config.json gains card-level keys (bluetoothConfig, options,
hardware, timeZone, gpio, deviceDisplay, advanced, powerConfig,
wifiOptions, ethernetOptionsCard, advancedCard, directMessageKey,
adminKeysCard, logsCard, administration, compassOrientation,
useBrowserTimeZone) and updates field labels to Android strings.xml
values where they differ (e.g. "Bluetooth enabled", "Always point
north", "Use 12h clock format", "Shutdown on power loss", "Wait for
Bluetooth duration", "Battery INA_2XX I2C address", "WiFi enabled",
"Ethernet enabled", "Legacy Admin channel", "Serial console").
2026-04-26 08:47:21 -04:00
Dan Ditomaso
3429e481d7 feat(web): pilot Position / LoRa / MQTT pages on ConfigEditor; Android-aligned layout + labels
First three settings pages migrated off the legacy changeRegistry to the
SDK ConfigEditor. Section structure, field order, and visible strings
are taken from the Meshtastic-Android source of truth so the web and
mobile UIs converge.

Position (radio config):
- Four cards in Android order: Position Packet → Device GPS → Position
  Flags → Advanced Device GPS.
- Card 1 fields: positionBroadcastSecs, positionBroadcastSmartEnabled,
  broadcastSmartMinimumIntervalSecs (renamed "Smart Interval"),
  broadcastSmartMinimumDistance ("Smart Distance").
- Card 2 fields: fixedPosition, lat/lng/altitude (when fixed), gpsMode
  ("GPS Mode (Physical Hardware)"), gpsUpdateInterval ("GPS Polling
  Interval"). gpsMode + gpsUpdateInterval disable when fixedPosition is
  on; lat/lng/altitude disable when it's off.
- Card 3 fields: positionFlags multiselect (description from Android).
- Card 4 fields: rxGpio ("GPS Receive GPIO"), txGpio ("GPS Transmit
  GPIO"), gpsEnGpio ("GPS EN GPIO").
- onSubmit now calls editor.setRadioSection("position", payload). The
  setFixedPosition admin message path is unchanged.

LoRa (radio config):
- Two cards: Options + Advanced (was three: Mesh / Waveform / Radio).
- Options: region, usePreset, modemPreset (when usePreset),
  bandwidth/spreadFactor/codingRate (when !usePreset).
- Advanced: ignoreMqtt, configOkToMqtt, txEnabled, overrideDutyCycle,
  hopLimit (now "Number of Hops", select 0..7), channelNum,
  sx126xRxBoostedGain ("RX Boosted Gain"), overrideFrequency
  ("Frequency Override"), txPower.
- Drop frequencyOffset (Android does not surface it).
- onSubmit calls editor.setRadioSection("lora", payload).

MQTT (module config):
- Two cards: MQTT Config + Map reporting (new card).
- Card 1: enabled ("MQTT enabled"), address, username, password,
  encryptionEnabled, jsonEnabled, tlsEnabled, root ("Root topic"),
  proxyToClientEnabled.
- Card 2: mapReportingEnabled, mapReportSettings.shouldReportLocation
  (consent gate, "I agree."), positionPrecision (12-15 buckets),
  publishIntervalSecs ("Map reporting interval (seconds)").
- positionPrecision + publishIntervalSecs disable until both
  mapReportingEnabled and shouldReportLocation are true.
- onSubmit calls editor.setModuleSection("mqtt", payload).
- Validation schema gains shouldReportLocation; address/username/
  password limits widened to 63 to match proto, root capped at 31.

Settings/index.tsx handleSave:
- After the legacy changeRegistry flow, runs editor.commit() when the
  editor is dirty. Two beginEdit/commitEdit windows are fine — the
  device handles them sequentially. Once the rest of the settings pages
  migrate, the legacy flow goes away and only editor.commit() remains.
- Save button gating includes editor.isDirty so users can save
  ConfigEditor-only changes (e.g. just a LoRa region tweak).

i18n:
- New section labels (config.json position.{positionPacket,deviceGps,
  advancedDeviceGps}; lora.{optionsCard,advancedCard}; moduleConfig.json
  mqtt.{mqttConfigCard,mapReportingCard}).
- Field labels reworded to match Android strings.xml.
- mqtt.mapReportSettings.shouldReportLocation gains label, description,
  consentHeader, consentText (consent text quoted verbatim from
  Android).
2026-04-25 23:11:09 -04:00
Dan Ditomaso
5a5755fb2b feat(sdk,sdk-react): scaffold ConfigEditor for the radio/module/channels edit flow
Replaces the (broken) web changeRegistry with a section-grain editor that
lives on the SDK. Per-connection state, signal-backed, exposed via
client.config.editor.

ConfigEditor (domain):
- baseline   = what the device most recently sent. Updated automatically
  from onConfigPacket / onModuleConfigPacket / onChannelPacket.
- working    = UI edits. setRadioSection / setModuleSection / setChannel.
- dirtyRadioSections / dirtyModuleSections / dirtyChannels signals plus
  an aggregate isDirty signal.
- Inbound baseline updates do NOT discard pending working changes — if a
  section is already dirty, the new baseline is recorded but working stays
  put; dirty bookkeeping recomputes against the updated baseline.
- reset() restores working = baseline.
- commit() wraps beginEditSettings → setConfig per dirty radio variant +
  setModuleConfig per dirty module variant + setChannel per dirty channel
  → commitEditSettings, then optimistically promotes working to baseline.
- Disconnect clears both baseline and working so a stale working copy can
  never leak across reconnects.

Domain types:
- RadioConfig and ModuleConfig are now derived directly from the proto
  Config / ModuleConfig payloadVariant union (Extract<{case: V}> shape) so
  new variants stay typed without manual list maintenance. This adds
  deviceUi (radio) and statusmessage (module) automatically.

sdk-react:
- New useConfigEditor() hook returns the editor for the active client, or
  undefined when no client is active. Components subscribe to the editor's
  signals via the existing useSignal helper.

Tests:
- 5 new ConfigEditor tests covering: clean start, dirty tracking, multi-
  section dirty, reset, mid-edit baseline update preservation, and
  disconnect-clears-state. SDK suite 49 passing.

This commit is scaffolding only — no web settings page rewires yet. The
~25 settings pages move to editor.set* / editor.commit() in follow-up
commits, replacing setChange / commitEditSettings call sites and unblocking
deletion of changeRegistry from the device store.
2026-04-25 22:47:17 -04:00
Dan Ditomaso
0f8525c962 feat(sdk-react,web): migrate read-only channel consumers to SDK ChannelsClient
Phase one of the channels-slice migration (PR #8 in plan). Moves the
non-changeRegistry-coupled consumers off the legacy deviceStore.channels
Map onto the SDK signal-backed ChannelsClient.

- sdk-react useChannels: switch from useClient to useActiveClient and
  fall back to an empty signal when no client is active. Matches the
  no-throw pattern already used by useNodes / useNodeErrors so isolated
  component tests and pre-connect renders don't throw.
- Messages.tsx reads channels via useChannels(); filteredChannels is
  derived with useMemo, currentChannel via .find(). The SDK Channel
  shape (index/role/settings) matches everything this page touches.
- QRDialog reads channels directly via useChannels(); the channels Map
  prop is dropped from QRDialogProps + DialogManager.
- getChannelName loosens its parameter type to the SDK-compatible
  subset { index, settings?: { name? } } so it accepts both proto
  Channel and SDK Channel without conversion.

Channels.tsx, ImportDialog and Settings/index.tsx still read from the
deviceStore — they are coupled to changeRegistry / setChange and will
move with PR #9 (ConfigEditor).
2026-04-25 22:38:43 -04:00
Dan Ditomaso
7aa5207194 chore(web): rename useChatLegacy → useChatAsLegacyMessages
The "legacy" suffix on its own read like the hook itself was deprecated;
the actual purpose is "use SDK chat history but project it into the
legacy Message shape." Rename for clarity. Companion type names follow
suit (UseChatAsLegacyMessages{Broadcast,Direct,Params}).
2026-04-25 22:30:20 -04:00
Dan Ditomaso
0342eca505 chore(web): retire Zustand messageStore — keep only Message types/enums
The persisted message Zustand store had no remaining consumers — chat
history, drafts, and message state all live on the SDK ChatClient
(SqlocalMessageRepository). The messageStore Zustand surface
(saveMessage, getMessages, setMessageState, getDraft, setDraft,
clearDraft, deleteAllMessages, clearMessageByMessageId, plus the
addMessageStore / removeMessageStore / getMessageStore / setNodeNum
plumbing) is now fully unused.

Collapse messageStore/index.ts to just the MessageState / MessageType
enums + the legacy `Message` shape that the useChatLegacy adapter and a
couple of message components still consume. Delete the Zustand
implementation and its 32-test suite.

Other knock-on cleanups in this commit:
- Drop the _messageStore param from subscribeAll (unused after the
  saveMessage-from-subscriptions retirement).
- Drop addMessageStore wiring from useConnections, removeMessageStore
  from FactoryResetDeviceDialog, setNodeNum from useNewNodeNum.
- Drop the message branch from the router context (no readers).
- RefreshKeysDialog stops keying off `useMessages().activeChat` (which
  was permanently 0 — a dead handle that quietly broke key-refresh
  UX). Use the SDK NodesClient's first error directly via
  `useNodeErrors()[0]`. The dialog manager already opens the dialog on
  PKI_UNKNOWN_PUBKEY, so picking the first error matches the intended
  flow.
2026-04-25 22:28:28 -04:00
Dan Ditomaso
f29b2ba93a chore(web): delete legacy nodeDBStore — SDK NodesClient is the only source of truth
The Zustand nodeDBStore had no remaining writers (the mirror in
subscriptions.ts was retired in 005d0000) and no remaining readers
outside of bookkeeping (addNodeDB / setNodeNum / removeNodeDB calls
that fed nothing). Drop the whole store along with its persistence
shim and the persistNodeDB feature flag.

- Delete packages/web/src/core/stores/nodeDBStore/.
- Drop the _nodeDB parameter from subscribeAll; it was unused.
- Drop addNodeDB / useNodeDBStore wiring from useConnections; the
  meshDeviceId reuse check now keys off the device store instead.
- useNewNodeNum no longer pokes the nodeDB.
- FactoryResetDeviceDialog drops its removeNodeDB call (and the test
  drops the matching expectation).
- Strip persistNodeDB / VITE_PERSIST_NODE_DB from featureFlags +
  dev-overrides.
- Remove the useNodeDB / useNodeDBStore re-exports + the
  bindStoreToDevice wiring from core/stores/index.ts.
2026-04-25 22:23:00 -04:00
Dan Ditomaso
15df8ec8dd chore(web): drop leftover useNodeDB from Position settings page
useMyNodeAsProto already replaces getMyNode here; the import was left
behind during the nodeDB mirror retirement.
2026-04-25 22:18:43 -04:00
Dan Ditomaso
005d000047 feat(sdk,web): SDK NodesClient owns user/position/lastHeard/snr +
favourite-flag flips; legacy mirror retired

The SDK NodesClient now subscribes to onUserPacket / onPositionPacket /
onMeshPacket so user-record updates, GPS updates, and lastHeard / snr
refreshes flow into the signal-backed store + repository directly. The
favourite / ignored toggles also flip the local flag once the admin
message resolves successfully, so the UI no longer needs to mirror the
state into a parallel Zustand store.

packages/sdk
- NodesClient: new private patch(num, partial) helper that shallow-
  merges into the existing entry (or seeds a placeholder Node) and
  upserts to the repository in one shot. Used by:
  - onUserPacket → patch user
  - onPositionPacket → patch position
  - onMeshPacket → patch lastHeard / snr (replaces the legacy nodeDB
    processPacket flow)
  - favorite / unfavorite / ignore / unignore — flag flip on Result.ok
- NodesClient.reset({ keepMyNode? }) — preserves the local node entry
  when requested. Mirrors the previous removeAllNodes(true) semantics
  the ResetNodeDb dialog relied on.

packages/web
- core/subscriptions.ts: drops the onUserPacket / onPositionPacket /
  onNodeInfoPacket / onMeshPacket → legacy nodeDB write paths. The
  unused nodeDB parameter is renamed `_nodeDB` for callsite stability;
  it will disappear when the store itself is deleted in a follow-up.
- core/hooks/useFavoriteNode + useIgnoreNode: drop the legacy
  updateFavorite / updateIgnore mirror — the SDK flips the flag on
  success.
- components/Dialog/RemoveNodeDialog: now calls meshClient.nodes.remove
  exclusively; no legacy fall-through.
- components/Dialog/ResetNodeDbDialog: calls
  meshClient.nodes.reset({ keepMyNode: true }) and meshClient.chat.clearAll().
  No legacy nodeDB calls.
- Test mocks for useFavoriteNode / useIgnoreNode / ResetNodeDbDialog
  pruned to match.

Test counts: sdk 44 (unchanged), sdk-react 8, sdk-storage-sqlocal 24,
web 290. Production Vite build clean.

Remaining surface on the legacy nodeDB: addNode / addUser / addPosition /
processPacket / setNodeError-equivalent are now unused; the store retains
only updateFavorite / updateIgnore (no callers) and the per-device
plumbing required by the deviceContext hooks. Full deletion of
nodeDBStore queued in the plan-file follow-up.
2026-04-25 22:15:24 -04:00
Dan Ditomaso
9037ac78cf chore(web): remove dead PKI / nodeError paths from legacy nodeDB
Now that the SDK NodesClient owns public-key validation and per-node
error tracking, the legacy Zustand mirrors are dead code.

packages/web/src/core/stores/nodeDBStore/index.ts
- Drops the nodeErrors map + setNodeError / getNodeError / hasNodeError /
  clearNodeError / removeAllNodeErrors methods from the NodeDB interface
  and factory.
- Drops the validateIncomingNode call inside addNode and inside
  setNodeNum's merge path; legacy mirror is now a straight last-write-
  wins shallow merge. The SDK runs validation independently against its
  own snapshot.
- Drops the nodeErrors entries from the persisted partialize shape.

packages/web/src/core/stores/nodeDBStore/types.ts
- Removes NodeError + NodeErrorType. ProcessPacketParams stays.

packages/web/src/core/stores/nodeDBStore/nodeValidation.ts — deleted
(SDK ports it at packages/sdk/src/features/nodes/infrastructure/
nodeValidation.ts and exposes the verdict via NodesClient).

packages/web/src/core/stores/index.ts — drop the dead NodeErrorType
re-export.

packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
- Removes tests covering the migrated PKI behaviour (errors map,
  MISMATCH, DUPLICATE, "unions nodeErrors") — equivalent coverage now
  lives in packages/sdk/src/features/nodes/NodesClient.errors.test.ts.
- Trims the surviving merge-semantics tests to the simpler last-write-
  wins shape.
- The "selector re-renders" test swaps the deleted setNodeError mutation
  for an updateFavorite call to keep the slice-stability assertion alive.
- The "addNodeDB instance identity" assertion relaxes from .toBe to
  content equality — immer's pruneStaleNodes path can reseat the entry,
  but the registered DB's id stays stable.

components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx — calls
meshClient.nodes.clearAllErrors() instead of the legacy
removeAllNodeErrors. Test mocks updated.

components/Dialog/RefreshKeysDialog/* — already migrated to SDK errors
in the previous commit; no further changes here.

Test counts: web 290 (was 295 — 5 PKI-tracking tests retired in
favour of 5 equivalent SDK tests). Production Vite build clean.
2026-04-25 22:08:32 -04:00
Dan Ditomaso
54a8103823 feat(sdk,sdk-react,web): node PKI / routing-error tracking on the SDK
Moves PKI mismatch / duplicate-key validation and routing-error tracking
out of the legacy Zustand nodeDBStore and into the SDK NodesClient so
every consumer reads the same source of truth. Validates Android's gap
analysis: Meshtastic-Android does not track per-node PKI errors at all,
so the SDK must own this concern client-side.

packages/sdk
- New NodeError + NodeErrorType (Routing_Error | "MISMATCH_PKI" |
  "DUPLICATE_PKI"). Mirrors the previous web-only types.
- New nodeValidation infrastructure mapper (pure): byte-equal compare on
  publicKey, returns ValidatedNodeInfo { accepted?, error? }. Ports the
  detection rules verbatim from
  packages/web/src/core/stores/nodeDBStore/nodeValidation.ts.
- NodesClient gains an errors signal + errorFor / hasError / setError /
  clearError / clearAllErrors API. handleIncoming runs validation before
  upserting; conflicts skip the store write and record an error. A clean
  refresh of a previously-flagged node clears the error.
- handleRoutingPacket records PKI_UNKNOWN_PUBKEY / PKI_FAILED /
  PKI_SEND_FAIL_PUBLIC_KEY / NO_CHANNEL against packet.from.
- 5 new vitest cases covering MISMATCH_PKI, DUPLICATE_PKI, error-clear-on-
  refresh, routing-error capture, clearError / clearAllErrors. SDK total:
  44 tests, all green.

packages/sdk-react
- New useNodeErrors / useNodeError(num) / useHasNodeError(num) hooks.
  All resolve through useActiveClient() and fall back to empty when no
  client is present.

packages/web
- pages/Nodes/index.tsx, pages/Messages.tsx, components/PageComponents/
  Map/Layers/NodesLayer.tsx swap useNodeDB().hasNodeError for the SDK
  useNodeErrors hook + a memoised Set lookup.
- components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx reads the
  current error from useNodeError(activeChat); useRefreshKeysDialog.ts
  drives meshClient.nodes.clearError + meshClient.nodes.remove instead
  of the legacy store. The companion test still passes through the
  empty-MeshRegistry render path because useNodeError tolerates a
  missing active client.
- core/subscriptions.ts no longer calls nodeDB.setNodeError on PKI /
  no-channel routing packets — the SDK records those itself. The dialog
  open trigger stays here.
- pages/Nodes/index.tsx drops the now-dead NODEDB_DEBOUNCE_MS constant.

Web tests: 295 still green; production Vite build clean. The legacy
nodeDB still runs its internal validation but its error map is now a
dead end — removal queued in the plan-file follow-up alongside the rest
of the nodeDB cleanup.
2026-04-25 21:55:19 -04:00
Dan Ditomaso
84e69bf6fd refactor(web): ResetNodeDb + RefreshKeys dialogs read SDK; safer adapter
- ResetNodeDbDialog: connection.resetNodes() → meshClient.nodes.reset();
  on success it now also calls meshClient.chat.clearAll() instead of the
  legacy useMessages().deleteAllMessages(). PKI-error tracking and the
  in-memory nodeDB still get cleared via removeAllNodeErrors /
  removeAllNodes since those subsystems have not yet migrated. Test
  rewritten to mock useActiveClient via vi.hoisted.
- RefreshKeysDialog: drops useNodeDB().getNode in favour of
  useNodeAsProto for the missing-key node display. Test wraps render in
  a MeshRegistryProvider with an empty registry so the adapter resolves
  cleanly with no active client.

Adapter hardening (useNodesAsProto.ts)
- Switched off useNodes() / useMeshDevice() (which both throw outside a
  MeshProvider/MeshRegistryProvider with an active client) onto
  useActiveClient() + a no-op signal fallback. The hooks now return [] /
  undefined when there is no active client instead of throwing, which
  fixes RefreshKeysDialog's "no error → render null" path under tests
  that don't connect a device.

Web tests: 295 still green; production Vite build clean.
2026-04-25 21:45:03 -04:00
Dan Ditomaso
cb789a8a26 refactor(web): useFavoriteNode + useIgnoreNode drive SDK NodesClient
The favorite/ignore admin-message paths now go through the SDK NodesClient
instead of the legacy Zustand sendAdminMessage + manual proto build.

- useFavoriteNode: meshClient.nodes.favorite(nodeNum) /
  meshClient.nodes.unfavorite(nodeNum). The SDK already has
  FavoriteNodeUseCase + AdminMessageSender behind these APIs.
- useIgnoreNode: meshClient.nodes.ignore / .unignore.
- Both still mirror the optimistic flag flip into the legacy nodeDB store
  via updateFavorite / updateIgnore until that store is fully retired —
  same TODO as before, "wait for ack before flipping".

Tests rewritten to mock @meshtastic/sdk-react via vi.hoisted (so the
factory captures the spy without hitting the vi.mock hoist trap):
- assert SDK favorite/unfavorite/ignore/unignore calls
- assert legacy store mirror still fires
- assert toast + longName fallback behaviour preserved
- assert no-op when the node isn't in the SDK store yet (parity with
  the prior "getNode returned undefined" guard)

Web tests: 295 still green; production Vite build clean.
2026-04-25 21:41:18 -04:00
Dan Ditomaso
b9f035b5ee refactor(web): rename node legacy adapters to *AsProto
The "Legacy" suffix on the node-adapter hooks reads as if the hook
itself is deprecated, when in fact the hook is the bridge: it converts
SDK Node domain entities into Protobuf.Mesh.NodeInfo so consumer
templates that destructure proto fields keep working during the
migration.

- useNodesLegacy → useNodesAsProto
- useNodeLegacy → useNodeAsProto
- useMyNodeLegacy → useMyNodeAsProto
- File renamed to packages/web/src/core/hooks/useNodesAsProto.ts.

All 16 call sites updated. Test mock in
packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx
updated to the new module path.

Behaviour unchanged. 295 tests still green; production Vite build clean.

(useChatLegacy follows the same pattern but maps to a hand-written web
type, not a proto — it stays as-is for now and is queued for renaming
once the broader chat-store cleanup lands.)
2026-04-25 21:18:18 -04:00
Dan Ditomaso
dd8bda1c4b refactor(web): migrate 9 nodeDB consumers onto SDK adapter
Sweeps the most-touched UI paths off useNodeDB().getNode/getMyNode/getNodes
and onto the useNodesLegacy / useNodeLegacy / useMyNodeLegacy adapters
introduced earlier. Behaviour parity preserved — the adapter returns the
same Protobuf.Mesh.NodeInfo shape components already render.

Migrated:
- components/Sidebar.tsx — myNode + node count from SDK signals.
- components/CommandPalette/index.tsx — node lookup for connection labels.
- components/UI/Avatar.tsx — single-node lookup.
- components/Dialog/RemoveNodeDialog.tsx — selected node display.
- components/Dialog/PKIBackupDialog.tsx — myNode for download/print headers.
- components/Dialog/LocationResponseDialog.tsx — sender lookup.
- components/Dialog/TracerouteResponseDialog.tsx — endpoint lookups.
- components/Dialog/NodeDetailsDialog.tsx — selected node detail.
- components/PageComponents/Settings/User.tsx — myNode for owner edit.
- components/PageComponents/Settings/Position.tsx — myNode for current position.
- components/PageComponents/Messages/TraceRoute.tsx — hop name lookup.
- components/PageComponents/Messages/MessageItem.tsx — myNode (suspending) +
  message author. The polling Suspense fallback now retriggers on the SDK
  signal instead of polling the Zustand store directly.
- components/PageComponents/Map/Popups/WaypointDetail.tsx — locked-to node.
- pages/Messages.tsx — sidebar node list + selected peer lookup.
- pages/Map/index.tsx — full filtered list + myNode for fitting. Drops the
  now-dead NODEDB_DEBOUNCE_MS constant since the SDK signal layer handles
  re-render coalescing internally.
- TraceRoute.test.tsx mocks updated to mock useNodesLegacy instead of
  useNodeDB.

NodesLayer.tsx, RemoveNodeDialog (removeNode), ResetNodeDbDialog, and
RefreshKeysDialog still depend on hasNodeError / removeNode /
removeAllNodes on the legacy nodeDB store — those move when PKI-error
tracking and the admin-message paths are migrated to the SDK in the
unread/cleanup follow-up.

Web tests: 295 still green; production Vite build clean. No SDK changes
in this commit.
2026-04-25 21:15:14 -04:00
Dan Ditomaso
c7db122ee8 refactor(web): NodesPage reads node list from SDK signals
First consumer migration off the legacy Zustand nodeDBStore onto
SDK-managed nodes. Pages/Nodes/index.tsx now pulls the full node array
through useNodesLegacy() — which subscribes to the SDK NodesClient
signal underneath — and applies the existing nodeFilter predicate
client-side via useMemo.

Behavior parity:
- Same Protobuf.Mesh.NodeInfo shape rendered in the table (the legacy
  adapter ensures channel / viaMqtt / hopsAway / publicKey survive).
- Same debounce semantics — only the underlying source changed.
- hasNodeError + nodeErrors continue to come from the Zustand
  nodeDBStore until PKI-error tracking is migrated to the SDK in a
  follow-up commit (validation logic still in
  packages/web/src/core/stores/nodeDBStore/nodeValidation.ts).

The legacy nodeDBStore still populates from subscriptions.ts, so this
rewrite is reversible and runs alongside the SDK source. Web tests
(295) still green; production Vite build clean.
2026-04-25 21:03:52 -04:00
Dan Ditomaso
17ca853597 feat(sdk,web): SDK Node carries hops/mqtt/key-verified; legacy adapter hook
Lays the groundwork for migrating web's 31 nodeDB consumers off the
Zustand `useNodeDB().getNodes/getNode` API onto SDK signals, without a
big-bang rewrite.

packages/sdk
- Node domain entity gains channel, viaMqtt, hopsAway, isKeyManuallyVerified
  fields. NodeMapper.fromProto now copies these from the inbound
  Protobuf.Mesh.NodeInfo. Required so downstream UIs that show "X hops
  away" / "via MQTT" / "encryption verified" can read SDK nodes without
  losing fidelity.

packages/web
- New core/hooks/useNodesLegacy.ts: useNodesLegacy() returns
  Protobuf.Mesh.NodeInfo[] derived from SDK signals; useNodeLegacy(num)
  returns a single NodeInfo. Components migrate one at a time by swapping
  useNodeDB().getNodes / getNode call sites for these hooks; templates
  stay unchanged because the result shape matches.

No consumer rewrites in this commit — that is per-component follow-up
work to keep diffs reviewable. Web tests (295) still green; production
build clean. SDK 39 / sdk-storage-sqlocal 24 unchanged.
2026-04-25 21:02:45 -04:00
Dan Ditomaso
9c6f8b9f03 feat(sdk,sdk-storage-sqlocal,web): NodesRepository port + SQLite persistence
Adds SDK-side node persistence so a fresh page load rehydrates the mesh
NodeDB from disk before any device packets arrive. Web's existing Zustand
nodeDBStore continues to work unchanged in parallel — UI consumer
migration to useNodes/useNode lands in follow-up commits.

packages/sdk
- New NodesRepository port: loadAll / get / upsert / upsertBatch / remove /
  clear. InMemoryNodesRepository ships as the default.
- NodesClient takes optional { repository }, hydrates on construction,
  writes through on every onNodeInfoPacket, and keeps live signal +
  persistence in lockstep. remove/reset clear the repository alongside the
  store + drive the legacy admin message.
- MeshClientOptions exposes a `nodes` slot mirroring the existing `chat`
  slot.

packages/sdk-storage-sqlocal
- New SqlocalNodesRepository implementing the port. user / position /
  deviceMetrics serialized as base64-encoded protobuf bytes (stable
  across schema additions). Subpath export at "@meshtastic/sdk-storage-
  sqlocal/nodes".
- 6 vitest cases covering upsert + loadAll round-trip, overwrite, proto
  round-trip preserves user fields, remove, clear, multi-device isolation.

packages/web
- useConnections opens a SqlocalNodesRepository alongside the chat /
  draft repos and passes it to the new MeshDevice constructor.

Test counts: sdk 39 (unchanged — repo round-trip exercised in storage
adapter tests), sdk-storage-sqlocal 24 (+6), web 295 unchanged. Build
clean.
2026-04-25 20:59:24 -04:00