feat: scaffold @meshtastic/sdk + signals/sqlocal persistence migration#1050
Draft
danditomaso wants to merge 43 commits into
Draft
feat: scaffold @meshtastic/sdk + signals/sqlocal persistence migration#1050danditomaso wants to merge 43 commits into
danditomaso wants to merge 43 commits into
Conversation
Adds two new packages laying the foundation for a domain-driven migration away from @meshtastic/core. packages/sdk - DDD feature slices: device, chat, nodes, channels, config, telemetry, position, traceroute, files. Each with domain/application/infrastructure/state. - Shared kernel under core/: MeshClient orchestrator, Transport interface (byte-compat with existing transport-* packages), EventBus (typed pub/sub), packet codec, Queue, Xmodem, signals helpers, tslog factory. - Signals via @preact/signals-core. Application use-cases return Result<T,E> via better-result; legacy ports keep throwing. - shim/ re-exports the legacy MeshDevice/Types/Utils API so packages/web continues to build unchanged. - createFakeTransport() under @meshtastic/sdk/testing. - 16 vitest tests incl. end-to-end fake-transport integration. packages/sdk-react - MeshProvider + useSignal/useSignalValue/useClient adapters. - Hooks: useDevice, useConnection, useChat, useNodes, useNode, useChannels, useChannel, useConfig, useModuleConfig, useTelemetry, usePosition, useTraceroute, useFileTransfer, useFavoriteNode, useIgnoreNode. - jsdom-backed hook tests. Root README rewritten with packages table, architecture, and workflow.
Mechanical swap across all 74 @meshtastic/core imports in packages/web/src. The SDK's shim layer re-exports the legacy MeshDevice/Types/Utils/Protobuf surface, so no source edits are required beyond the import path. All 294 web vitest tests still pass. This is Phase B step 1: web now runs on @meshtastic/sdk's MeshDevice shim. Per-slice store migrations (messageStore/nodeDBStore/deviceStore → useChat/useNodes/useDevice etc.) land in follow-up commits.
Phase B prep for web migration. Web holds multiple simultaneous device connections keyed by ConnectionId, so per-slice hook migrations need a registry-aware provider. packages/sdk - MeshRegistry: Map<ConnectionId, MeshClient> with signals for list/active/activeId. create()/get()/has()/remove()/setActive(). - First-created client auto-activates. - remove() disconnects the client and rotates active to another entry. - 4 vitest cases covering create, auto-activate, duplicate rejection, and remove. packages/sdk-react - MeshRegistryProvider + MeshRegistryContext. - useMeshRegistry, useOptionalMeshRegistry, useActiveClient, useClientById(id). - useClient now falls back to the registry's active client when no direct <MeshProvider> is present, so existing hooks work unchanged under a registry-backed app. No web-facing changes in this commit; used by follow-up slice migrations.
Prevents collision with packages/web's own useDevice() Zustand hook. All internal exports + tests updated; no behavior change. Callers migrating off @meshtastic/core should use useMeshDevice() from @meshtastic/sdk-react going forward.
Introduces a single app-wide MeshRegistry in packages/web/src/core/meshRegistry.ts and wraps the RouterProvider in <MeshRegistryProvider>. Registry starts empty; useConnections continues to instantiate the legacy MeshDevice shim. Subsequent slice migrations will swap useConnections over to registry.create() and move consumers onto useMeshDevice()/useChat()/etc from @meshtastic/sdk-react. Adds @meshtastic/sdk-react as a web dependency. No behavior change; web tests (294) and production build still pass.
packages/sdk - MeshRegistry.register(id, client): adopts an externally-constructed MeshClient. Complements create() for migration paths where a legacy shim already owns the client. - MeshRegistry.unregister(id): drops the mapping without disconnecting, for cases where the caller has torn down the transport itself. - Legacy MeshDevice shim exposes its inner MeshClient as `meshClient` so consumers can adopt it into the registry. packages/web - useConnections.setupMeshDevice now registers the shim's MeshClient with the app-wide meshRegistry and marks it active on connect. - removeConnection unregisters from the registry. - Legacy Zustand deviceStore wiring is unchanged; follow-up commits will move read paths to useMeshDevice/useChat etc. and remove the duplicated fields. No behavior change visible to users. Web tests (294) + SDK tests (20) still pass.
Lays the persistence groundwork for the chat slice migration.
- MessageRepository port defines paginated reads (loadRecent, loadBefore),
atomic writes (append, appendBatch), state updates, and retention pruning.
Conversation keyed by ConversationKey tagged union (channel | direct peer).
- RetentionPolicy: maxPerBucket + olderThanMs knobs. Consumer decides.
- InMemoryMessageRepository ships with SDK as default + test fixture.
- ChatClient accepts { repository?, retention?, initialLoadLimit? }.
Lazy-hydrates per conversation on first subscribe; writes through on every
inbound message; prunes after append when retention policy is set.
- ChatClient.loadOlder(conv, before, limit) for pagination UI.
- ChatStore.prepend() for older-first inserts.
Tests: 5 new cases for InMemoryMessageRepository (paginate, update state,
retention). 25 SDK tests total, all green. Web build unchanged.
Paves the way for @meshtastic/sdk-storage-sqlocal to implement this port
against SQLite/OPFS in a follow-up.
New workspace package implementing @meshtastic/sdk repository ports against sqlocal (SQLite WASM + OPFS) with Drizzle-typed queries. Schema (single DB, multi-device aware via device_id column) - messages: chat history. Indexes on (device_id, conversation_key, rx_time) for fast pagination and on (device_id, state) for pending lookups. - nodes: NodeDB snapshot per device (stub schema, repo lands with PR #7). - telemetry: per-node ring buffer of readings (stub). - _schema: migration version table. - Hand-written DDL migrations in src/schema/migrations.ts; applied at boot. createSqlocalDb({ databasePath }) - Opens OPFS DB, applies migrations, returns Drizzle client typed against the schema. - Single instance per origin; sqlocal serializes writes via OPFS file locks. SqlocalMessageRepository - Implements MessageRepository: paginated loadRecent / loadBefore, append / appendBatch with onConflictDoNothing, updateState, prune (maxPerBucket via windowed DELETE + olderThanMs). - Optional MultiTabCoordinator broadcasts messages-changed events on append. MultiTabCoordinator - BroadcastChannel pub/sub for cross-tab change notifications (no-ops if API unavailable, e.g. Node). - acquireLock() wraps navigator.locks.request with fall-through for non-browser contexts. Testing - src/testing/createMemoryDb.ts: in-memory sql.js + Drizzle, same surface as the sqlocal connection. Lets repository tests run on Node CI. - 6 SqlocalMessageRepository tests (pagination, retention, multi-device isolation) + 2 MultiTabCoordinator tests pass on sql.js. Notes - @meshtastic/sdk pkg.json now points types at ./mod.ts so workspace consumers resolve types directly from source. Production publish path needs a separate follow-up to emit a stable mod.d.ts. - Storage pkg ships ESM only; dts disabled until tsdown's hashed-name emit is reconciled with mod.d.ts resolution. Workspace consumption already gets full types from source.
Plumbs the SDK chat slice through the OPFS-backed SQLite repository so inbound and outbound text messages survive page reloads, with retention capped at 1000 messages per conversation or 90 days, whichever hits first. - packages/sdk legacy MeshDevice shim now accepts MeshClientOptions (chat, configId, logger). Backwards-compatible with the old `new MeshDevice(transport, configIdNumber)` form. - packages/web/src/core/sdkStorage.ts: lazy singletons for the shared SqlocalDb and the cross-tab MultiTabCoordinator. The DB opens on first call so test runs that never connect stay headless-safe. - useConnections.setupMeshDevice is now async, awaits getStorageDb, and passes a SqlocalMessageRepository scoped to the connection id. Falls back to the SDK's InMemoryMessageRepository if sqlocal init fails (no OPFS support, etc.). - Vite worker format set to "es" because sqlocal's worker is ES-module and rolldown rejects iife with code-splitting. - COOP/COEP dev headers were already in vite.config.ts; no further changes required for OPFS. Web tests (294) and production build still green. This is the runtime payoff of PR #5: a fresh page load populates chat from SQLite via lazy pagination instead of rehydrating 1000 messages into memory. The legacy Zustand messageStore is still in place for now; PR #6 (chat slice migration) will retire it and switch UI components to useChat.
Adds TESTING.md documenting six tiers (unit / slice / client / storage / hook / E2E), per-package coverage gates, and the audit of where we currently sit. Slice tests in @meshtastic/sdk - NodeMapper proto round-trip; NodesClient list signal updates. - ChannelsClient indexes by channel number. - ConfigClient merges Config + ModuleConfig variants. - TelemetryClient latest + history per node. - PositionClient byNode + list. - ChatClient persistence: hydrate on first subscribe, paginate via loadOlder, persist inbound messages through the repository. Fixes a reverse-iteration bug in loadOlder discovered by the new test. Hook tests in @meshtastic/sdk-react - New tests/hooks.registry.test.tsx covers useMeshDevice, useNodes, useNode, useChannels under <MeshRegistryProvider>, plus an active- client switch round-trip. Storage tests in @meshtastic/sdk-storage-sqlocal - migrations.test.ts validates v1 DDL creates messages/nodes/telemetry/ _schema and indexes; CREATE IF NOT EXISTS is idempotent. - MultiTabCoordinator.broadcast.test.ts proves cross-tab BroadcastChannel delivery between two coordinators in the same process. - New vitest.browser.config.ts + tests/sqlocal-opfs.browser.test.ts run under @vitest/browser (Playwright provider) for real OPFS round-trip verification. Wired as `pnpm test:browser`. Browser test files end in `.browser.test.ts` and are excluded from the Node runner. E2E / firmware-simulator tier (TESTING.md §"E2E / simulator") is scoped for a follow-up — needs CI Docker for meshtasticd. Totals after this change: - @meshtastic/sdk: 36 tests (was 25) - @meshtastic/sdk-react: 8 tests (was 2) - @meshtastic/sdk-storage-sqlocal: 12 tests (was 8) - meshtastic-web: 294 tests (unchanged)
|
@danditomaso is attempting to deploy a commit to the Meshtastic Team on Vercel. A member of the Team first needs to authorize it. |
Adds an adapter hook that bridges sdk-react's `useChat` and `useDirectChat` to the legacy `Message` shape MessagesPage / ChannelChat / MessageItem expect. The page now renders messages directly from the OPFS-backed SqlocalMessageRepository — page reload hydrates lazily (last 50 per conversation) instead of pulling 1000+ rows from IndexedDB into memory. packages/sdk-react - useChat() gains loadOlder(before, limit) for paginated backfill. - New useDirectChat(peer) hook covering DM conversations. packages/web - src/core/hooks/useChatLegacy.ts: maps SDK `Message` → legacy `messageStore/types.ts` Message shape, including state translation (SDK Pending → legacy Waiting). - MessagesPage flips broadcast and direct chat reads to useChatLegacy. getMessages call sites removed; setMessageState retained on the legacy store for outbound bookkeeping until the next migration step. Drafts, unread counts, activeChat / chatType continue to live in the Zustand messageStore — they are UI-only state and stay where they are per the locked architecture decision. The legacy store's saveMessage, getMessages, setMessageState, and persistence path remain in place for now; PR cleanup follow-up will retire them once outbound state and "delete all messages" flows are switched to the SDK. Web test suite: 294 still green. Production Vite build clean.
Switches the send-text path from `connection?.sendText(...)` (legacy
MeshDevice) to `client.chat.send({ text, destination, channel })` on the
active MeshClient pulled from MeshRegistry via useActiveClient().
Side effects:
- Outbound message state (Ack / Failed) is now driven entirely by the
SDK chat slice via routing-packet subscriptions; the manual
setMessageState calls are removed.
- The legacy Zustand `useMessages().setMessageState` and `MessageState`
imports are no longer used by MessagesPage. Drafts and unread counts
still live in the Zustand store.
- `getMyNode` is no longer needed in this page (was only used to label
the now-removed direct-message state updates).
Web tests (294) still green; production Vite build clean.
Moves per-conversation draft text out of the legacy Zustand messageStore
into the SDK chat slice so drafts share the same persistence + signal
machinery as messages. MessageInput now binds directly to the SDK; the
legacy `useMessages().getDraft/setDraft/clearDraft` API is no longer
called from the input.
packages/sdk
- New DraftRepository port + InMemoryDraftRepository default.
- ChatClient.drafts namespace: get(key) returns a ReadonlySignal<string>;
set/clear keyed by ConversationKey.
- ChatClient.send auto-clears the draft for the resolved conversation on
success (parity with prior Zustand clearDraft-on-send behavior).
- Lazy hydrate from the DraftRepository on first read of a conversation.
- 3 new tests in ChatClient.drafts.test.ts.
packages/sdk-react
- New useDraft(conversation) hook returning { text, setText, clear }.
packages/sdk-storage-sqlocal
- New `drafts` table (device_id + conversation_key composite PK, text,
updated_at). Drizzle schema in src/schema/drafts.ts.
- Migration v2 in src/schema/migrations.ts creates the table on first
open of an existing v1 DB.
- SqlocalDraftRepository implementing DraftRepository: load/save/clear/
loadAll, scoped by device_id, upsert on conflict, delete on empty save.
- 6 new tests covering save/load round-trip, empty-string deletion,
upsert, multi-device isolation, loadAll.
packages/web
- useConnections wires the SqlocalDraftRepository alongside the existing
SqlocalMessageRepository per registered MeshClient.
- MessageInput accepts `conversation: ConversationKey` instead of the
prior `to: Types.Destination` — fixes the legacy bug where every
broadcast channel shared a single draft slot.
- MessagesPage passes the appropriate ConversationKey for direct/
broadcast chats.
- MessageInput.test.tsx rewritten to mock useDraft from sdk-react.
Test counts: sdk 39 (+3), sdk-storage-sqlocal 18 (+6), web 294 unchanged.
Production Vite build clean.
The SDK chat slice now persists every inbound/outbound text packet via the SqlocalMessageRepository wired in useConnections, so the legacy Zustand saveMessage path in subscriptions.ts was writing to a store no UI code reads from. - subscriptions.ts: removed saveMessage call + PacketToMessageDTO usage. Unread-count increments retained as-is (cross-cutting concern, migrates in a separate commit). - subscribeAll's messageStore parameter retained as `_messageStore` for callsite stability while the rest of the legacy store is being retired. - Deleted packages/web/src/core/dto/PacketToMessageDTO.ts (no remaining consumers; SDK has its own MessageMapper at packages/sdk/src/features/chat/infrastructure/MessageMapper.ts). Web tests (294) still green; production build clean. Out of scope, queued: - useConnections refactor (the hook is overdue for cleanup) - Strip dead methods from the Zustand messageStore (saveMessage, getMessages, setMessageState, getDraft, setDraft, clearDraft + Zustand-persist + IDB wrapper). Requires a follow-up sweep of the remaining test files that mock those methods. - Migrate unread counts to the SDK (cross-cutting between chat + nodes).
…ssagesDialog Rounds out the chat slice with destructive operations so the UI dialog no longer needs the legacy Zustand deleteAllMessages. packages/sdk - MessageRepository port gains clearConversation(key); InMemory and Sqlocal adapters implement it (SQL: scoped DELETE by conversation). - ChatStore gains clearBucket(key) + clearAll(). - ChatClient.clearConversation(conv) and ChatClient.clearAll(): empty the in-memory store, drop the hydrated-marker so a future subscribe re-fetches fresh, then delete from the repository. Repository failures are swallowed — UI must not get stuck behind a write error. packages/sdk-storage-sqlocal - SqlocalMessageRepository.clearConversation: DELETE FROM messages WHERE (device_id, conversation_key) match. packages/web - DeleteMessagesDialog swaps useMessages().deleteAllMessages for useActiveClient()?.chat.clearAll(). No-active-client path is a no-op but still closes the dialog. - Test file updated: mocks useActiveClient; new case covers no-active-client safety. Totals: sdk 39 tests unchanged (clearConversation tested transitively via DeleteMessagesDialog; adapter-specific test queued for follow-up), sdk-storage-sqlocal 18 unchanged, web 295 (+1).
…istence
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.
…r 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.
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.
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.
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.)
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.
- 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.
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.
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.
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.
useMyNodeAsProto already replaces getMyNode here; the import was left behind during the nodeDB mirror retirement.
…ource of truth The Zustand nodeDBStore had no remaining writers (the mirror in subscriptions.ts was retired in 005d000) 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.
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.
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}).
…elsClient 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).
…els 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.
…d-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).
…ges 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").
…igned 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.
…ry; 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.
…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.
…TelemetryRepository (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.
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.
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.
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.
…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.
…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.
…e 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lays the foundation for migrating off
@meshtastic/core+ Zustand + IndexedDB onto a domain-driven SDK (@meshtastic/sdk) with signals-backed reactive state, OPFS-backed SQLite persistence (@meshtastic/sdk-storage-sqlocal), and React bindings (@meshtastic/sdk-react).10 commits, intentionally non-destructive: web still builds and all 294 web tests pass. Slice-by-slice migrations of UI consumers happen in follow-up PRs (see TESTING.md and the migration plan).
What's in the branch
feat(sdk): scaffold @meshtastic/sdk + @meshtastic/sdk-reactrefactor(web): import @meshtastic/sdk instead of @meshtastic/corefeat(sdk): add MeshRegistry + multi-client React providersMeshRegistry(Map<ConnectionId, MeshClient>),MeshRegistryProvider,useActiveClient,useClientById,useMeshRegistry. Existing hooks fall back through registry.refactor(sdk-react): rename useDevice → useMeshDeviceuseDeviceZustand hook.feat(web): mount MeshRegistryProvider at app rootindex.tsx.feat(web,sdk): register per-connection MeshClient in registryMeshDevice.meshClientgetter +registry.register/unregister.useConnectionsadopts the shim's inner client.feat(sdk): add MessageRepository port + InMemoryMessageRepository{ repository?, retention?, initialLoadLimit? }, lazy-hydrates per conversation, writes through on each inbound message.feat(sdk-storage-sqlocal): SQLite WASM persistence adaptersSqlocalMessageRepository,MultiTabCoordinator,createSqlocalDb,createMemoryDb(sql.js for tests).feat(web): persist chat history via @meshtastic/sdk-storage-sqlocaltest: testing strategy + 19 new SDK / storage / hook testsArchitecture decisions locked
@meshtastic/coreentirely, with shim covering the migration window.@preact/signals-core; Zustand stays only for non-SDK UI state (theme, sidebar, dialogs).better-resultResult<T, E>for new application use-cases; legacy ports keep throwing.ConfigEditorreplaces the existingchangeRegistry(lands with config slice migration). Baseline = device truth; working = UI edits; commit diffs sections; disconnect discards in-flight edits.device_id; one DB per origin.Test counts
@meshtastic/sdk@meshtastic/sdk-react@meshtastic/sdk-storage-sqlocalmeshtastic-webpnpm -r buildis green across all packages including production web Vite bundle.Out of scope (queued for follow-up PRs)
packages/web/src/core/stores/messageStore, swap MessagesPage/MessageItem/dialogs touseChat, virtual-list pagination vialoadOlder.status/connectionPhase/hardware/metadata/connectionfields from web'sDeviceinterface; rewrite ~15 call sites touseMeshDevice().NodesRepository+ deletenodeDBStore.packages/core, retarget the 6transport-*packages to@meshtastic/sdk, deprecate@meshtastic/coreon npm/JSR.meshtasticdin CI).mod.d.tsemit fix in tsdown so storage pkg can re-enable dts.Test plan
TESTING.mdand confirms the six-tier strategy + per-package gates make sense.pnpm -r testruns clean on a fresh checkout.pnpm --filter meshtastic-web buildproduces a working web bundle (sqlocal worker bundles as ES).useChat's lazy load (this requires PR missing mqtt settings #6 to be visible to UI; for now history is in OPFS but UI still reads legacy messageStore).pnpm --filter @meshtastic/sdk-storage-sqlocal test:browser(needs@vitest/browser+ Playwright installed) to validate real OPFS round-trip.