feat: scaffold @meshtastic/sdk + signals/sqlocal persistence migration (#1050)

* feat(sdk): scaffold @meshtastic/sdk + @meshtastic/sdk-react

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.

* refactor(web): import @meshtastic/sdk instead of @meshtastic/core

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.

* feat(sdk): add MeshRegistry + multi-client React providers

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.

* refactor(sdk-react): rename useDevice → useMeshDevice

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.

* feat(web): mount MeshRegistryProvider at app root

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.

* feat(web,sdk): register per-connection MeshClient in registry

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.

* feat(sdk): add MessageRepository port + InMemoryMessageRepository

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.

* feat(sdk-storage-sqlocal): SQLite WASM persistence adapters

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.

* feat(web): persist chat history via @meshtastic/sdk-storage-sqlocal

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.

* test: testing strategy + 19 new SDK / storage / hook tests

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)

* feat(web): MessagesPage reads chat history from SDK / SQLite

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.

* feat(web): MessagesPage outbound send through SDK ChatClient

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.

* feat(sdk,web): drafts persisted in SDK chat slice (SQLite-backed)

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.

* chore(web): drop saveMessage path from subscriptions, delete dead DTO

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).

* feat(sdk,web): ChatClient.clearConversation + clearAll; wire DeleteMessagesDialog

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).

* 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.

* 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.

* 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.

* 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.

* 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.)

* 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.

* 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.

* 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.

* 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.

* 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.

* chore(web): drop leftover useNodeDB from Position settings page

useMyNodeAsProto already replaces getMyNode here; the import was left
behind during the nodeDB mirror retirement.

* 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.

* 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.

* 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}).

* 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).

* 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.

* 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).

* 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").

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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();

* 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.

* 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.*`.

* 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.

* 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`.

* 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.

* 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.

* 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.

* 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.

* adding a claude to repo

* handle device reboots

* chore(web): post-rebase fixups — lockfile, dedupe dep, formatting

- Regenerate pnpm-lock.yaml to match the merged package.json set (adds the
  @meshtastic/sdk + transport workspace packages from this PR); the rebase
  had held the lockfile at main's version via a merge driver.
- Drop a duplicate better-result entry in apps/web/package.json.
- Reformat LoRa.tsx / Display.tsx (oxfmt) after merging main's config fields
  into Dan's ConfigEditor field groups.

* test: retire main's nodeDBStore + messageStore tests superseded by SDK migration

Dan's SDK migration removed the Zustand nodeDBStore (node state now lives in
the SDK; useNodesLegacy notes the migration) and the useNodeDB hook. main had
since added nodeDBStore.test.tsx / messageStore.test.ts and kept evolving the
store; the rebase carried those over, so they tested an API this PR removes
(useNodeDB is not a function). Dan's branch contains none of these files.

Remove the dead nodeDBStore store dir and the two orphaned main tests to match
the migrated architecture. The live messageStore (barrel-exported) is kept; only
main's standalone test for it is dropped.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Dan Ditomaso
2026-06-15 17:55:49 -04:00
committed by GitHub
parent ccc02ad438
commit dc9749c042
386 changed files with 13841 additions and 7155 deletions

View File

@@ -17,7 +17,7 @@ jobs:
uses: crowdin/github-action@v2
with:
base_url: "https://meshtastic.crowdin.com/api/v2"
config: "./packages/web/crowdin.yml"
config: "./apps/web/crowdin.yml"
upload_sources: false
upload_translations: false
download_translations: true

View File

@@ -5,7 +5,7 @@ on:
# Monitor all .json files within the /i18n/locales/en/ directory.
# This ensures the workflow triggers if any the English namespace files are modified on the main branch.
paths:
- "/packages/web/public/i18n/locales/en/*.json"
- "/apps/web/public/i18n/locales/en/*.json"
branches: [main]
workflow_dispatch: # Allow manual triggering
@@ -21,7 +21,7 @@ jobs:
uses: crowdin/github-action@v2
with:
base_url: "https://meshtastic.crowdin.com/api/v2"
config: "./packages/web/crowdin.yml"
config: "./apps/web/crowdin.yml"
upload_sources: true
upload_translations: false
download_translations: false

View File

@@ -15,7 +15,7 @@ jobs:
uses: crowdin/github-action@v2
with:
base_url: "https://meshtastic.crowdin.com/api/v2"
config: "./packages/web/crowdin.yml"
config: "./apps/web/crowdin.yml"
upload_sources: false
upload_translations: true
download_translations: false

View File

@@ -44,7 +44,7 @@ jobs:
run: |
set -euo pipefail
if [ "${{ github.event.inputs.packages }}" = "all" ] || [ -z "${{ github.event.inputs.packages }}" ]; then
mapfile -t TARGETS < <(ls -d packages/* | grep -v 'packages/web')
mapfile -t TARGETS < <(ls -d packages/*)
else
IFS=',' read -ra TARGETS <<< "${{ github.event.inputs.packages }}"
TARGETS=("${TARGETS[@]/#/packages/}")

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ packages/protobufs/packages/ts/dist
*.pem
*.crt
*.key
# Claude Code session locks
.claude/

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"]
"recommendations": ["bradlc.vscode-tailwindcss", "oxc.oxc-vscode"]
}

9
CLAUDE.md Normal file
View File

@@ -0,0 +1,9 @@
# Meshtastic Web Client - Claude Code Guide
@AGENTS.md
## Claude-Specific Instructions
- **Think First:** Always outline your step-by-step reasoning inside `<thinking>` tags before writing code or shell commands. Claude models perform significantly better on complex TypeScript tasks when they "think out loud" first.
- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task.
- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `<copilot_cli_workflow>` section.

208
README.md
View File

@@ -9,32 +9,151 @@
## Overview
This monorepo consolidates the official [Meshtastic](https://meshtastic.org) web
interface and its supporting JavaScript libraries. It aims to provide a unified
development experience for interacting with Meshtastic devices.
interface, the domain-driven JavaScript SDK that drives it, and a set of
runtime-specific transport packages. Everything you need to read state from or
send commands to a Meshtastic device lives here.
> [!NOTE]
> You can find the main Meshtastic documentation at https://meshtastic.org/docs/introduction/.
### Projects within this Monorepo (`packages/`)
## Packages
All projects are located within the `packages/` directory:
All projects live under `packages/`.
- **`packages/web` (Meshtastic Web Client):** The official web interface,
designed to be hosted or served directly from a Meshtastic node.
- **[Hosted version](https://client.meshtastic.org)**
- **`packages/core`:** Core functionality for Meshtastic JS.
- **`packages/transport-node`:** TCP Transport for the NodeJS runtime.
- **`packages/transport-node-serial`:** NodeJS Serial Transport for the NodeJS runtime.
- **`packages/transport-deno`:** TCP Transport for the Deno runtime.
- **`packages/transport-http`:** HTTP Transport.
- **`packages/transport-web-bluetooth`:** Web Bluetooth Transport.
- **`packages/transport-web-serial`:** Web Serial Transport.
- **`packages/protobufs`:** Git submodule containing Meshtastics shared protobuf definitions, used to generate and publish the JSR protobuf package.
| Package | Purpose |
| --- | --- |
| `packages/sdk` | Framework-agnostic TypeScript SDK. Domain-driven feature slices (device, chat, nodes, channels, config, telemetry, position, traceroute, files) built around a `MeshClient` orchestrator with `@preact/signals-core` reactive state. |
| `packages/sdk-react` | React hooks + `MeshProvider` on top of `@meshtastic/sdk`. Wraps signals in `useSyncExternalStore` for concurrent-safe renders. |
| `apps/web` | Reference React web client. Hosted at [client.meshtastic.org](https://client.meshtastic.org). |
| `packages/ui` | Shared Radix + Tailwind component library. |
| `packages/protobufs` | Generated TypeScript stubs from [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs), produced via `buf generate`. Source of truth for every wire-level type. |
| `packages/transport-http` | HTTP transport for devices exposing a network interface. |
| `packages/transport-web-bluetooth` | Web Bluetooth transport for BLE-capable devices (browsers). |
| `packages/transport-web-serial` | Web Serial transport for USB-serial devices (browsers). |
| `packages/transport-node` | TCP transport for Node.js. |
| `packages/transport-node-serial` | Serial transport for Node.js. |
| `packages/transport-deno` | TCP transport for Deno. |
| `packages/transport-mock` | In-memory transport for tests. |
All `Meshtastic JS` packages (core and transports) are published both to
[JSR](https://jsr.io/@meshtastic). [NPM](https://www.npmjs.com/org/meshtastic)
All publishable packages ship to both [JSR](https://jsr.io/@meshtastic) and [NPM](https://www.npmjs.com/org/meshtastic).
---
## Architecture
`@meshtastic/sdk` organises its source by feature slice. Each slice follows the
same DDD layout:
```
features/<slice>/
domain/ # entities & value objects — pure TypeScript types
application/ # use-cases (SendTextUseCase, FavoriteNodeUseCase, …)
infrastructure/ # protobuf ↔ domain mappers, admin-message adapters
state/ # signals-backed reactive stores
<Slice>Client.ts # public facade exposing readable signals + command methods
index.ts
```
The shared kernel under `packages/sdk/src/core/` owns:
- **`client/`** — `MeshClient`, the thin orchestrator that owns the transport, queue, event bus, and one instance of every slice client.
- **`transport/`** — the `Transport` interface every `transport-*` package implements.
- **`event-bus/`** — typed pub/sub channels populated by the packet codec.
- **`packet-codec/`** — frame parser (0x94 0xC3), `FromRadio` decoder, portnum router.
- **`queue/`**, **`xmodem/`** — packet ack/timeout pipeline and file-transfer protocol.
- **`signals/`** — signal and keyed-collection helpers consumed by every slice.
- **`logging/`** — `tslog` factory used consistently by every class.
- **`identifiers/`**, **`errors/`** — small primitives shared across slices.
The protobuf boundary is strict: wire messages enter through the packet codec,
get mapped into domain entities inside `features/*/infrastructure/*Mapper.ts`,
and signals only ever expose the domain shape.
```
Transport ─▶ Packet codec ─▶ EventBus ─▶ Slice infrastructure
Signals (state) ─▶ sdk-react hooks ─▶ UI
Slice application (use-cases) ─▶ MeshClient.sendPacket ─▶ Queue ─▶ Transport
```
Expected domain errors are returned as `Result<T, E>` via [`better-result`](https://www.npmjs.com/package/better-result); exceptions are reserved for programmer errors and truly exceptional conditions.
## Getting Started
### Prerequisites
You need [pnpm](https://pnpm.io/) installed. If you plan to regenerate
protobufs, also install the [Buf CLI](https://buf.build/docs/cli/installation/).
### Setup
```bash
git clone https://github.com/meshtastic/web.git
cd web
pnpm install
```
### Run the web client
```bash
pnpm --filter @meshtastic/web dev
```
### Build everything
```bash
pnpm -r build
```
### Run tests
```bash
pnpm -r test
```
### Lint + format
```bash
pnpm check
pnpm check:fix
```
## Developing
### Adding a new feature slice to `@meshtastic/sdk`
1. Create `packages/sdk/src/features/<slice>/` with `domain/`, `application/`, `infrastructure/`, `state/` subdirectories plus an `index.ts` barrel.
2. Implement a signals-backed store in `state/`.
3. Subscribe to the relevant `EventBus` channel(s) inside a `<Slice>Client.ts` class and write mapped domain entities to the store.
4. Wire the new client into `MeshClient` and re-export types from `packages/sdk/mod.ts`.
5. Add vitest coverage: domain invariants, use-cases against `createFakeTransport()`, and round-trip mapper fixtures.
6. If the slice has React callers, add a matching hook under `packages/sdk-react/src/hooks/`.
### Adding a new transport
Implement the `Transport` interface exported from `@meshtastic/sdk/transport`:
```ts
interface Transport {
toDevice: WritableStream<Uint8Array>;
fromDevice: ReadableStream<DeviceOutput>;
disconnect(): Promise<void>;
}
```
The SDK does the framing and decoding — transports only supply raw bytes.
### Testing
Vitest is wired at the repo root and picks up `packages/*` projects. The SDK
ships `@meshtastic/sdk/testing` with `createFakeTransport()` for wiring tests
without real hardware.
## Publishing
Each publishable package has `build:npm` / `publish:npm` / `prepare:jsr` /
`publish:jsr` scripts. See each package's `package.json` for details.
## Repository activity
@@ -42,56 +161,11 @@ All `Meshtastic JS` packages (core and transports) are published both to
| :------------- | :-------------------------------------------------------------------------------------------------------------------- |
| Meshtastic Web | ![Alt](https://repobeats.axiom.co/api/embed/e5b062db986cb005d83e81724c00cb2b9cce8e4c.svg "Repobeats analytics image") |
---
## Tech Stack
This monorepo leverages the following technologies:
- **Runtime:** pnpm / Deno
- **Web Client:** React.js
- **Styling:** Tailwind CSS
- **Bundling:** Vite
- **Language:** TypeScript
- **Testing:** Vitest, React Testing Library
---
## Getting Started
### Prerequisites
You'll need to have [pnpm](https://pnpm.io/) installed to work with this monorepo.
Follow the installation instructions on their home page.
### Development Setup
1. **Clone the repository:**
```bash
git clone https://github.com/meshtastic/meshtastic-web.git
cd meshtastic-web
```
2. **Install dependencies for all packages:**
```bash
pnpm install
```
This command installs all necessary dependencies for all packages within the
monorepo.
3. **Install the Buf CLI**
Required for building `packages/protobufs`
https://buf.build/docs/cli/installation/
### Running Projects
#### Meshtastic Web Client
Please refer to the [Meshtastic Web README](packages/web/README.md) for setup and usage.
### Feedback
## Feedback
If you encounter any issues, please report them in our
[issues tracker](https://github.com/meshtastic/web/issues). Your feedback helps
improve the stability of future releases
improve the stability of future releases.
## Star history
@@ -108,3 +182,7 @@ improve the stability of future releases
<a href="https://github.com/meshtastic/web/graphs/contributors">
<img src="https://contrib.rocks/image?repo=meshtastic/web" width="100%"/>
</a>
## License
GPL-3.0-only. See [LICENSE](LICENSE).

83
TESTING.md Normal file
View File

@@ -0,0 +1,83 @@
# Testing strategy
How this monorepo proves correctness, and where coverage currently sits.
## Levels
Tests live in five tiers. Each PR should add coverage at the **lowest level that catches the regression**, and only climb tiers when that's not possible.
| Tier | Scope | Tooling | Where |
| --- | --- | --- | --- |
| 1. **Unit** | Pure functions, value objects, mappers, single classes with mocked deps | `vitest run` (Node env) | `*.test.ts` colocated with source |
| 2. **Slice integration** | Use-case + store + mapper exercised against in-memory deps; assert outbound bytes and signal state | `vitest` + `createFakeTransport()` | `*.test.ts` in slice dirs |
| 3. **Client integration** | `MeshClient` end-to-end with a fake transport feeding canned `FromRadio` packets | `vitest` + `@meshtastic/sdk/testing` | `packages/sdk/tests/integration/` |
| 4. **Storage integration** | Real Drizzle queries against sql.js in-memory; or against `@vitest/browser` for OPFS | `vitest` (Node + browser) | `packages/sdk-storage-sqlocal/{src,tests}` |
| 5. **Hook / DOM** | React hooks render under `<MeshProvider>` / `<MeshRegistryProvider>`, react to signals | `vitest` + `@testing-library/react` + `jsdom` | `packages/sdk-react/tests/` |
| 6. **E2E / simulator** | Whole stack: SDK → transport → simulator/firmware. Catches protocol drift | `@vitest/browser` for OPFS; `meshtasticd` simulator over TCP for protocol | future `tests/e2e/` |
## Per-package coverage gates
| Package | Required floor | Notes |
| --- | --- | --- |
| `packages/sdk/core` | every primitive (signals, EventBus, Queue, packet-codec, identifiers) has a unit test; lifecycle covered by `MeshClient.test.ts` | adopt `c8` thresholds once stable |
| `packages/sdk/features/*` | each slice ships: 1 domain invariant test, 1 use-case test against fake transport, 1 mapper round-trip (where mappers exist) | Integration covered by `tests/integration/fake-transport.test.ts` |
| `packages/sdk-react` | each public hook has a `renderHook` test that asserts initial render + re-render on signal change | uses jsdom; provider wrapper required |
| `packages/sdk-storage-sqlocal` | every repository method tested against sql.js in-memory; **at least one OPFS-real test per repo** runs in browser mode | sql.js validates SQL correctness; OPFS validates VFS / Worker plumbing |
| `packages/transport-*` | minimum: framing round-trip, disconnect cleans up streams, error path emits status | low-level; ship as is |
| `packages/web` | component tests at `34` baseline; new SDK-driven UI components must add a hook-mock test | currently all green |
## Current state (audit)
| Package | Test files | Tests pass | Gaps |
| --- | --- | --- | --- |
| `packages/sdk` | 7 | 25 ✅ | nodes/channels/config/telemetry/position/traceroute/files slices have **no tests**; no `MeshClient` lifecycle test (only fake-transport integration); no schema migration test |
| `packages/sdk-react` | 1 | 2 ✅ | only `useMeshDevice` + a stubbed `useChat` test; missing `useNodes`, `useChannels`, `useConfig`, `useConnection`, `useTraceroute`, `useTelemetry`, `usePosition`, `useFileTransfer`, `useFavoriteNode`, `useIgnoreNode`, `useMeshRegistry`, `useClientById`, `useActiveClient`. No registry-aware re-render coverage. |
| `packages/sdk-storage-sqlocal` | 2 | 8 ✅ | sql.js only — **no real OPFS test**, no Worker boot test, no cross-tab BroadcastChannel test (mocked away), no migration v1→v2 test |
| `packages/web` | 34 | 294 ✅ | no `useConnections` test; new `meshRegistry` + `sdkStorage` modules untested; chat persistence end-to-end not exercised |
| `packages/transport-*` | 1 each (5 of 7) | varies | `transport-deno` + `transport-mock` have no tests; transports likely lack disconnect/error coverage |
| `packages/core` | 0 | n/a | legacy, slated for deletion in Phase C; tolerable |
| `packages/ui` | 0 | n/a | pure presentational; visual regression only |
| `packages/protobufs` | 0 | n/a | generated code; upstream's responsibility |
## Concrete additions queued (priority order)
1. **`packages/sdk` slice tests** — one Use-case + one Mapper test per slice (`nodes`, `channels`, `config`, `telemetry`, `position`, `traceroute`). Pattern: build a stub `MeshClient`, dispatch a synthetic event, assert signal value or outbound bytes.
2. **`packages/sdk-react` hook tests** — for every hook listed above, mount under `<MeshProvider>`, drive a signal change, assert `result.current`. One file, ~15 cases.
3. **`packages/sdk` `ChatClient` persistence test** — wire `InMemoryMessageRepository`, append messages, re-construct `ChatClient`, assert hydration. Validates the lazy-load contract.
4. **`packages/sdk-storage-sqlocal` migration test** — bootstrap empty DB, run `MIGRATIONS[]`, assert `_schema.version`. Then add a fake `version: 2` migration and prove it's applied idempotently.
5. **`packages/sdk-storage-sqlocal` browser mode** — add a second `vitest.browser.config.ts` using `@vitest/browser` (Playwright provider) so we exercise real OPFS + Worker. CI runs both modes.
6. **`packages/sdk-storage-sqlocal` BroadcastChannel test** — instantiate two `MultiTabCoordinator` instances in same process; one broadcasts, the other observes. (Node has no `BroadcastChannel` global; use `worker_threads`'s `BroadcastChannel` polyfill or jsdom env.)
7. **`packages/web` `meshRegistry` + `sdkStorage` lazy-init test** — assert `getStorageDb()` returns the same promise on repeated calls and only opens the DB once.
8. **`packages/sdk-react` registry test** — mount `<MeshRegistryProvider>` with two clients, switch active, confirm a hook re-renders against the new client.
## E2E / simulator (Tier 6) — scope only
Out of immediate scope; documenting for a follow-up PR.
- Run `meshtasticd` (firmware simulator) in CI Docker via `services:` block.
- Spin up `MeshClient` with `TransportHTTP` pointed at the simulator's HTTP endpoint.
- Drive scripted scenarios: configure → send text → expect ack; channel update; node info exchange; traceroute.
- Use `@vitest/browser` so we also exercise the real OPFS persistence path during E2E.
- Run on `main` only (cost). Smoke subset on PR.
Until that lands, `createFakeTransport()` covers the protocol layer at unit/integration speed.
## Conventions
- Test file colocated with source: `Foo.ts``Foo.test.ts`.
- Integration tests under `tests/integration/`.
- Browser-mode tests use the suffix `.browser.test.ts` so they can be filtered.
- Protobuf fixtures live in `__fixtures__/*.fixtures.ts` next to mappers; binary data committed as base64, not raw bytes.
- Each test imports concrete classes from the source path (`./Foo.ts`), not the package barrel — fast type-check, zero re-export drift.
- No mocked SDK from inside SDK tests. Use `createFakeTransport()` and real `MeshClient` instances.
## Running
```sh
pnpm -r test # all packages, Node env
pnpm --filter @meshtastic/sdk test
pnpm --filter @meshtastic/sdk-storage-sqlocal test
pnpm --filter meshtastic-web test
# future:
pnpm --filter @meshtastic/sdk-storage-sqlocal test:browser
```

1
apps/web/.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@@ -33,7 +33,9 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@meshtastic/core": "workspace:*",
"@meshtastic/sdk": "workspace:*",
"@meshtastic/sdk-react": "workspace:*",
"@meshtastic/sdk-storage-sqlocal": "workspace:*",
"@meshtastic/transport-http": "workspace:*",
"@meshtastic/transport-web-bluetooth": "workspace:*",
"@meshtastic/transport-web-serial": "workspace:*",

View File

@@ -14,7 +14,7 @@
"tabTelemetry": "Telemetry"
},
"ambientLighting": {
"title": "Ambient Lighting Settings",
"title": "Ambient Lighting",
"description": "Settings for the Ambient Lighting module",
"ledState": {
"label": "LED State",
@@ -35,217 +35,253 @@
"blue": {
"label": "Blue",
"description": "Sets the blue LED level. Values are 0-255"
},
"ambientLightingConfig": {
"label": "Ambient Lighting Config",
"description": "Ambient Lighting configuration"
}
},
"audio": {
"title": "Audio Settings",
"title": "Audio",
"description": "Settings for the Audio module",
"codec2Enabled": {
"label": "Codec 2 Enabled",
"label": "CODEC 2 enabled",
"description": "Enable Codec 2 audio encoding"
},
"pttPin": {
"label": "PTT Pin",
"label": "PTT pin",
"description": "GPIO pin to use for PTT"
},
"bitrate": {
"label": "Bitrate",
"label": "CODEC2 sample rate",
"description": "Bitrate to use for audio encoding"
},
"i2sWs": {
"label": "i2S WS",
"label": "I2S word select",
"description": "GPIO pin to use for i2S WS"
},
"i2sSd": {
"label": "i2S SD",
"label": "I2S data in",
"description": "GPIO pin to use for i2S SD"
},
"i2sDin": {
"label": "i2S DIN",
"label": "I2S data out",
"description": "GPIO pin to use for i2S DIN"
},
"i2sSck": {
"label": "i2S SCK",
"label": "I2S clock",
"description": "GPIO pin to use for i2S SCK"
},
"audioConfig": {
"label": "Audio Config",
"description": "Audio configuration"
}
},
"cannedMessage": {
"title": "Canned Message Settings",
"title": "Canned Message",
"description": "Settings for the Canned Message module",
"moduleEnabled": {
"label": "Module Enabled",
"description": "Enable Canned Message"
},
"rotary1Enabled": {
"label": "Rotary Encoder #1 Enabled",
"label": "Rotary encoder #1 enabled",
"description": "Enable the rotary encoder"
},
"inputbrokerPinA": {
"label": "Encoder Pin A",
"label": "GPIO pin for rotary encoder A port",
"description": "GPIO Pin Value (1-39) For encoder port A"
},
"inputbrokerPinB": {
"label": "Encoder Pin B",
"label": "GPIO pin for rotary encoder B port",
"description": "GPIO Pin Value (1-39) For encoder port B"
},
"inputbrokerPinPress": {
"label": "Encoder Pin Press",
"label": "GPIO pin for rotary encoder Press port",
"description": "GPIO Pin Value (1-39) For encoder Press"
},
"inputbrokerEventCw": {
"label": "Clockwise event",
"label": "Generate input event on CW",
"description": "Select input event."
},
"inputbrokerEventCcw": {
"label": "Counter Clockwise event",
"label": "Generate input event on CCW",
"description": "Select input event."
},
"inputbrokerEventPress": {
"label": "Press event",
"label": "Generate input event on Press",
"description": "Select input event"
},
"updown1Enabled": {
"label": "Up Down enabled",
"label": "Up/Down/Select input enabled",
"description": "Enable the up / down encoder"
},
"allowInputSource": {
"label": "Allow Input Source",
"label": "Allow input source",
"description": "Select from: '_any', 'rotEnc1', 'upDownEnc1', 'cardkb'"
},
"sendBell": {
"label": "Send Bell",
"label": "Send bell",
"description": "Sends a bell character with each message"
},
"cannedMessageConfig": {
"label": "Canned Message Config",
"description": "Canned Message configuration"
},
"enabled": {
"label": "Canned message enabled",
"description": "Enable Canned Message"
}
},
"detectionSensor": {
"title": "Detection Sensor Settings",
"title": "Detection Sensor",
"description": "Settings for the Detection Sensor module",
"enabled": {
"label": "Enabled",
"label": "Detection Sensor enabled",
"description": "Enable or disable Detection Sensor Module"
},
"minimumBroadcastSecs": {
"label": "Minimum Broadcast Seconds",
"description": "The interval in seconds of how often we can send a message to the mesh when a state change is detected"
"label": "Minimum broadcast (seconds)",
"description": "Minimum interval between detection broadcasts"
},
"stateBroadcastSecs": {
"label": "State Broadcast Seconds",
"description": "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes"
"label": "State broadcast (seconds)",
"description": "How often to broadcast detection state"
},
"sendBell": {
"label": "Send Bell",
"description": "Send ASCII bell with alert message"
"label": "Send bell with alert message",
"description": "Send a bell character with the alert"
},
"name": {
"label": "Friendly Name",
"description": "Used to format the message sent to mesh, max 20 Characters"
"label": "Friendly name",
"description": "Friendly name for the detected event"
},
"monitorPin": {
"label": "Monitor Pin",
"description": "The GPIO pin to monitor for state changes"
"label": "GPIO pin to monitor",
"description": "GPIO pin to monitor for detection"
},
"detectionTriggerType": {
"label": "Detection Triggered Type",
"description": "The type of trigger event to be used"
"label": "Detection trigger type",
"description": "Trigger type for detection"
},
"usePullup": {
"label": "Use Pullup",
"description": "Whether or not use INPUT_PULLUP mode for GPIO pin"
"label": "Use INPUT_PULLUP mode",
"description": "Enable internal pull-up on the monitor pin"
},
"detectionSensorConfig": {
"label": "Detection Sensor Config",
"description": "Detection Sensor configuration"
}
},
"externalNotification": {
"title": "External Notification Settings",
"title": "External Notification",
"description": "Configure the external notification module",
"enabled": {
"label": "Module Enabled",
"label": "External notification enabled",
"description": "Enable External Notification"
},
"outputMs": {
"label": "Output MS",
"label": "Output duration (milliseconds)",
"description": "Output MS"
},
"output": {
"label": "Output",
"label": "Output LED (GPIO)",
"description": "Output"
},
"outputVibra": {
"label": "Output Vibrate",
"label": "Output vibra (GPIO)",
"description": "Output Vibrate"
},
"outputBuzzer": {
"label": "Output Buzzer",
"label": "Output buzzer (GPIO)",
"description": "Output Buzzer"
},
"active": {
"label": "Active",
"label": "Output LED active high",
"description": "Active"
},
"alertMessage": {
"label": "Alert Message",
"label": "Alert message LED",
"description": "Alert Message"
},
"alertMessageVibra": {
"label": "Alert Message Vibrate",
"label": "Alert message vibra",
"description": "Alert Message Vibrate"
},
"alertMessageBuzzer": {
"label": "Alert Message Buzzer",
"label": "Alert message buzzer",
"description": "Alert Message Buzzer"
},
"alertBell": {
"label": "Alert Bell",
"label": "Alert bell LED",
"description": "Should an alert be triggered when receiving an incoming bell?"
},
"alertBellVibra": {
"label": "Alert Bell Vibrate",
"label": "Alert bell vibra",
"description": "Alert Bell Vibrate"
},
"alertBellBuzzer": {
"label": "Alert Bell Buzzer",
"label": "Alert bell buzzer",
"description": "Alert Bell Buzzer"
},
"usePwm": {
"label": "Use PWM",
"label": "Use PWM buzzer",
"description": "Use PWM"
},
"nagTimeout": {
"label": "Nag Timeout",
"label": "Nag timeout (seconds)",
"description": "Nag Timeout"
},
"useI2sAsBuzzer": {
"label": "Use I²S Pin as Buzzer",
"description": "Designate I²S Pin as Buzzer Output"
"label": "Use I2S as buzzer",
"description": "Use I2S output as buzzer driver"
},
"externalNotificationConfig": {
"label": "External Notification Config",
"description": "External notification configuration"
},
"notificationsOnMessage": {
"label": "Notifications on message receipt",
"description": "Alerts on incoming text messages"
},
"notificationsOnAlert": {
"label": "Notifications on alert/bell receipt",
"description": "Alerts on incoming bell/alert packets"
},
"advanced": {
"label": "Advanced",
"description": "Pin assignments and timing"
}
},
"mqtt": {
"title": "MQTT Settings",
"description": "Settings for the MQTT module",
"title": "MQTT",
"description": "MQTT module configuration",
"enabled": {
"label": "Enabled",
"label": "MQTT enabled",
"description": "Enable or disable MQTT"
},
"address": {
"label": "MQTT Server Address",
"label": "Address",
"description": "MQTT server address to use for default/custom servers"
},
"username": {
"label": "MQTT Username",
"label": "Username",
"description": "MQTT username to use for default/custom servers"
},
"password": {
"label": "MQTT Password",
"label": "Password",
"description": "MQTT password to use for default/custom servers"
},
"encryptionEnabled": {
"label": "Encryption Enabled",
"label": "Encryption enabled",
"description": "Enable or disable MQTT encryption. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set. This includes position data."
},
"jsonEnabled": {
"label": "JSON Enabled",
"label": "JSON output enabled",
"description": "Whether to send/consume JSON packets on MQTT"
},
"tlsEnabled": {
"label": "TLS Enabled",
"label": "TLS enabled",
"description": "Enable or disable TLS"
},
"root": {
@@ -253,20 +289,20 @@
"description": "MQTT root topic to use for default/custom servers"
},
"proxyToClientEnabled": {
"label": "MQTT Client Proxy Enabled",
"label": "Proxy to client enabled",
"description": "Utilizes the network connection to proxy MQTT messages to the client."
},
"mapReportingEnabled": {
"label": "Map Reporting Enabled",
"description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name."
"label": "Map reporting",
"description": "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, long and short name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name."
},
"mapReportSettings": {
"publishIntervalSecs": {
"label": "Map Report Publish Interval (s)",
"label": "Map reporting interval (seconds)",
"description": "Interval in seconds to publish map reports"
},
"positionPrecision": {
"label": "Approximate Location",
"label": "Approximate location",
"description": "Position shared will be accurate within this distance",
"options": {
"metric_km23": "Within 23 km",
@@ -290,78 +326,108 @@
"imperial_ft300": "Within 300 feet",
"imperial_ft150": "Within 150 feet"
}
},
"shouldReportLocation": {
"label": "I agree.",
"description": "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.",
"consentHeader": "Consent to Share Unencrypted Node Data via MQTT",
"consentText": "By enabling this feature, you acknowledge and expressly consent to the transmission of your device's real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions."
}
},
"mqttConfigCard": {
"label": "MQTT Config",
"description": "MQTT broker connection"
},
"mapReportingCard": {
"label": "Map reporting",
"description": "Periodic unencrypted map report"
}
},
"neighborInfo": {
"title": "Neighbor Info Settings",
"title": "Neighbor Info",
"description": "Settings for the Neighbor Info module",
"enabled": {
"label": "Enabled",
"label": "Neighbor Info enabled",
"description": "Enable or disable Neighbor Info Module"
},
"updateInterval": {
"label": "Update Interval",
"label": "Update interval (seconds)",
"description": "Interval in seconds of how often we should try to send our Neighbor Info to the mesh"
},
"neighborInfoConfig": {
"label": "Neighbor Info Config",
"description": "Neighbor Info configuration"
},
"transmitOverLora": {
"label": "Transmit over LoRa",
"description": "Whether NeighborInfo should be transmitted over LoRa. Not available on a channel with default key and name."
}
},
"paxcounter": {
"title": "Paxcounter Settings",
"title": "Paxcounter",
"description": "Settings for the Paxcounter module",
"enabled": {
"label": "Module Enabled",
"label": "Paxcounter enabled",
"description": "Enable Paxcounter"
},
"paxcounterUpdateInterval": {
"label": "Update Interval (seconds)",
"label": "Update interval (seconds)",
"description": "How long to wait between sending paxcounter packets"
},
"wifiThreshold": {
"label": "WiFi RSSI Threshold",
"label": "WiFi RSSI threshold (defaults to -80)",
"description": "At what WiFi RSSI level should the counter increase. Defaults to -80."
},
"bleThreshold": {
"label": "BLE RSSI Threshold",
"label": "BLE RSSI threshold (defaults to -80)",
"description": "At what BLE RSSI level should the counter increase. Defaults to -80."
},
"paxcounterConfig": {
"label": "Paxcounter Config",
"description": "Paxcounter configuration"
}
},
"rangeTest": {
"title": "Range Test Settings",
"title": "Range Test",
"description": "Settings for the Range Test module",
"enabled": {
"label": "Module Enabled",
"label": "Range test enabled",
"description": "Enable Range Test"
},
"sender": {
"label": "Message Interval",
"label": "Sender message interval (seconds)",
"description": "How long to wait between sending test packets"
},
"save": {
"label": "Save CSV to storage",
"label": "Save .CSV in storage (ESP32 only)",
"description": "ESP32 Only"
},
"rangeTestConfig": {
"label": "Range Test Config",
"description": "Range Test configuration"
}
},
"serial": {
"title": "Serial Settings",
"title": "Serial",
"description": "Settings for the Serial module",
"enabled": {
"label": "Module Enabled",
"label": "Serial enabled",
"description": "Enable Serial output"
},
"echo": {
"label": "Echo",
"label": "Echo enabled",
"description": "Any packets you send will be echoed back to your device"
},
"rxd": {
"label": "Receive Pin",
"label": "RX",
"description": "Set the GPIO pin to the RXD pin you have set up."
},
"txd": {
"label": "Transmit Pin",
"label": "TX",
"description": "Set the GPIO pin to the TXD pin you have set up."
},
"baud": {
"label": "Baud Rate",
"label": "Serial baud rate",
"description": "The serial baud rate"
},
"timeout": {
@@ -369,23 +435,27 @@
"description": "Seconds to wait before we consider your packet as 'done'"
},
"mode": {
"label": "Mode",
"label": "Serial mode",
"description": "Select Mode"
},
"overrideConsoleSerialPort": {
"label": "Override Console Serial Port",
"label": "Override console serial port",
"description": "If you have a serial port connected to the console, this will override it."
},
"serialConfig": {
"label": "Serial Config",
"description": "Serial module configuration"
}
},
"storeForward": {
"title": "Store & Forward Settings",
"title": "Store & Forward",
"description": "Settings for the Store & Forward module",
"enabled": {
"label": "Module Enabled",
"label": "Store & Forward enabled",
"description": "Enable Store & Forward"
},
"heartbeat": {
"label": "Heartbeat Enabled",
"label": "Heartbeat",
"description": "Enable Store & Forward heartbeat"
},
"records": {
@@ -399,50 +469,66 @@
"historyReturnWindow": {
"label": "History return window",
"description": "Return records from this time window (minutes)"
},
"storeForwardConfig": {
"label": "Store & Forward Config",
"description": "Store & Forward configuration"
},
"isServer": {
"label": "Server",
"description": "Run as a Store & Forward server"
}
},
"telemetry": {
"title": "Telemetry Settings",
"title": "Telemetry",
"description": "Settings for the Telemetry module",
"deviceUpdateInterval": {
"label": "Device Metrics",
"label": "Device metrics update interval",
"description": "Device metrics update interval (seconds)"
},
"environmentUpdateInterval": {
"label": "Environment metrics update interval (seconds)",
"label": "Environment metrics update interval",
"description": ""
},
"environmentMeasurementEnabled": {
"label": "Module Enabled",
"label": "Environment metrics module enabled",
"description": "Enable the Environment Telemetry"
},
"environmentScreenEnabled": {
"label": "Displayed on Screen",
"label": "Environment metrics on-screen enabled",
"description": "Show the Telemetry Module on the OLED"
},
"environmentDisplayFahrenheit": {
"label": "Display Fahrenheit",
"label": "Environment metrics use Fahrenheit",
"description": "Display temp in Fahrenheit"
},
"airQualityEnabled": {
"label": "Air Quality Enabled",
"label": "Air quality metrics module enabled",
"description": "Enable the Air Quality Telemetry"
},
"airQualityInterval": {
"label": "Air Quality Update Interval",
"label": "Air quality metrics update interval",
"description": "How often to send Air Quality data over the mesh"
},
"powerMeasurementEnabled": {
"label": "Power Measurement Enabled",
"label": "Power metrics module enabled",
"description": "Enable the Power Measurement Telemetry"
},
"powerUpdateInterval": {
"label": "Power Update Interval",
"label": "Power metrics update interval",
"description": "How often to send Power data over the mesh"
},
"powerScreenEnabled": {
"label": "Power Screen Enabled",
"description": "Enable the Power Telemetry Screen"
"label": "Power metrics on-screen enabled",
"description": "Show power metrics on the device screen"
},
"telemetryConfig": {
"label": "Telemetry Config",
"description": "Telemetry configuration"
},
"deviceTelemetryEnabled": {
"label": "Send Device Telemetry",
"description": "Enable/Disable the device telemetry module to send metrics to the mesh."
}
}
}

View File

@@ -3,6 +3,7 @@
"apply": "Apply",
"addConnection": "Add Connection",
"saveConnection": "Save connection",
"saveChanges": "Save changes",
"backupKey": "Backup Key",
"cancel": "Cancel",
"connect": "Connect",

View File

@@ -16,15 +16,15 @@
"label": "Configuration"
},
"device": {
"title": "Device Settings",
"title": "Device",
"description": "Settings for the device",
"buttonPin": {
"description": "Button pin override",
"label": "Button Pin"
"label": "Button GPIO"
},
"buzzerPin": {
"description": "Buzzer pin override",
"label": "Buzzer Pin"
"label": "Buzzer GPIO"
},
"disableTripleClick": {
"description": "Disable triple click",
@@ -43,8 +43,8 @@
"label": "Node Info Broadcast Interval"
},
"posixTimezone": {
"description": "The POSIX timezone string for the device",
"label": "POSIX Timezone"
"description": "Time zone for dates on the device screen and log.",
"label": "Time zone"
},
"rebroadcastMode": {
"description": "How to handle rebroadcasting",
@@ -52,52 +52,75 @@
},
"role": {
"description": "What role the device performs on the mesh",
"label": "Role"
"label": "Device Role"
},
"options": {
"label": "Options",
"description": "Device role, rebroadcast mode, and node-info broadcast cadence"
},
"hardware": {
"label": "Hardware",
"description": "Hardware-related toggles"
},
"timeZone": {
"label": "Time Zone",
"description": "POSIX time zone for dates on the device screen and log"
},
"gpio": {
"label": "GPIO",
"description": "GPIO pin overrides"
},
"useBrowserTimeZone": {
"label": "Use browser time zone"
}
},
"bluetooth": {
"title": "Bluetooth Settings",
"title": "Bluetooth",
"description": "Settings for the Bluetooth module",
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"enabled": {
"description": "Enable or disable Bluetooth",
"label": "Enabled"
"label": "Bluetooth enabled"
},
"pairingMode": {
"description": "Pin selection behaviour.",
"label": "Pairing mode"
},
"pin": {
"description": "Pin to use when pairing",
"label": "Pin"
"description": "PIN to use when pairing in fixed-PIN mode",
"label": "Fixed PIN"
},
"bluetoothConfig": {
"label": "Bluetooth Config",
"description": "Bluetooth configuration"
}
},
"display": {
"description": "Settings for the device display",
"title": "Display Settings",
"title": "Display",
"headingBold": {
"description": "Bolden the heading text",
"label": "Bold Heading"
},
"carouselDelay": {
"description": "How fast to cycle through windows",
"label": "Carousel Delay"
"label": "Carousel interval"
},
"compassNorthTop": {
"description": "Fix north to the top of compass",
"label": "Compass North Top"
"label": "Always point north"
},
"displayMode": {
"description": "Screen layout variant",
"label": "Display Mode"
"label": "Display mode"
},
"displayUnits": {
"description": "Display metric or imperial units",
"label": "Display Units"
"label": "Display units"
},
"flipScreen": {
"description": "Flip display 180 degrees",
"label": "Flip Screen"
"label": "Flip screen"
},
"gpsDisplayUnits": {
"description": "Coordinate display format",
@@ -105,35 +128,47 @@
},
"oledType": {
"description": "Type of OLED screen attached to the device",
"label": "OLED Type"
"label": "OLED type"
},
"screenTimeout": {
"description": "Turn off the display after this long",
"label": "Screen Timeout"
"label": "Screen on for"
},
"twelveHourClock": {
"description": "Use 12-hour clock format",
"label": "12-Hour Clock"
"description": "Display time in 12h format",
"label": "Use 12h clock format"
},
"wakeOnTapOrMotion": {
"description": "Wake the device on tap or motion",
"label": "Wake on Tap or Motion"
"label": "Wake on tap or motion"
},
"enableMessageBubbles": {
"label": "Message Bubbles",
"description": "Display incoming messages as bubbles on the device screen"
},
"deviceDisplay": {
"label": "Device Display",
"description": "Display orientation and clock"
},
"advanced": {
"label": "Advanced",
"description": "Advanced display settings"
},
"compassOrientation": {
"label": "Compass orientation",
"description": "Compass orientation relative to the screen"
}
},
"lora": {
"title": "Mesh Settings",
"description": "Settings for the LoRa mesh",
"title": "LoRa",
"description": "LoRa radio configuration",
"bandwidth": {
"description": "Channel bandwidth in kHz",
"label": "Bandwidth"
},
"boostedRxGain": {
"description": "Boosted RX gain",
"label": "Boosted RX Gain"
"label": "RX Boosted Gain"
},
"codingRate": {
"description": "The denominator of the coding rate",
@@ -144,24 +179,24 @@
"label": "Frequency Offset"
},
"frequencySlot": {
"description": "LoRa frequency channel number",
"description": "Your node's operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name and will change from the default public slot.",
"label": "Frequency Slot"
},
"hopLimit": {
"description": "Maximum number of hops",
"label": "Hop Limit"
"description": "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.",
"label": "Number of Hops"
},
"ignoreMqtt": {
"description": "Don't forward MQTT messages over the mesh",
"label": "Ignore MQTT"
},
"modemPreset": {
"description": "Modem preset to use",
"label": "Modem Preset"
"description": "Available modem presets, default is Long Fast.",
"label": "Presets"
},
"okToMqtt": {
"description": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
"label": "OK to MQTT"
"label": "Ok to MQTT"
},
"overrideDutyCycle": {
"description": "Override Duty Cycle",
@@ -169,10 +204,10 @@
},
"overrideFrequency": {
"description": "Override frequency",
"label": "Override Frequency"
"label": "Frequency Override"
},
"region": {
"description": "Sets the region for your node",
"description": "The region where you will be using your radios.",
"label": "Region"
},
"spreadingFactor": {
@@ -210,15 +245,23 @@
"serialHalOnly": {
"label": "Serial HAL Only",
"description": "Communicate with the radio exclusively over the serial hardware abstraction layer"
},
"optionsCard": {
"label": "Options",
"description": "LoRa core options"
},
"advancedCard": {
"label": "Advanced",
"description": "Advanced LoRa parameters"
}
},
"network": {
"title": "WiFi Config",
"description": "WiFi radio configuration",
"title": "Network",
"description": "Network configuration",
"note": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.",
"addressMode": {
"description": "Address assignment selection",
"label": "Address Mode"
"label": "IPv4 mode"
},
"dns": {
"description": "DNS Server",
@@ -226,7 +269,7 @@
},
"ethernetEnabled": {
"description": "Enable or disable the Ethernet port",
"label": "Enabled"
"label": "Ethernet enabled"
},
"gateway": {
"description": "Default Gateway",
@@ -238,7 +281,7 @@
},
"psk": {
"description": "Network password",
"label": "PSK"
"label": "Password"
},
"ssid": {
"description": "Network name",
@@ -250,16 +293,17 @@
},
"wifiEnabled": {
"description": "Enable or disable the WiFi radio",
"label": "Enabled"
"label": "WiFi enabled"
},
"meshViaUdp": {
"label": "Mesh via UDP"
"label": "Enabled",
"description": "Forward mesh packets over UDP"
},
"ntpServer": {
"label": "NTP Server"
"label": "NTP server"
},
"rsyslogServer": {
"label": "Rsyslog Server"
"label": "rsyslog server"
},
"ethernetConfigSettings": {
"description": "Ethernet port configuration",
@@ -280,18 +324,30 @@
"udpConfigSettings": {
"description": "UDP over Mesh configuration",
"label": "UDP Config"
},
"wifiOptions": {
"label": "WiFi Options",
"description": "WiFi configuration"
},
"ethernetOptionsCard": {
"label": "Ethernet Options",
"description": "Ethernet configuration"
},
"advancedCard": {
"label": "Advanced",
"description": "NTP, rsyslog, IPv4 mode and address overrides"
}
},
"position": {
"title": "Position Settings",
"description": "Settings for the position module",
"title": "Position",
"description": "Position settings",
"broadcastInterval": {
"description": "How often your position is sent out over the mesh",
"description": "The maximum interval that can elapse without a node broadcasting a position.",
"label": "Broadcast Interval"
},
"enablePin": {
"description": "GPS module enable pin override",
"label": "Enable Pin"
"description": "GPS enable GPIO pin override",
"label": "GPS EN GPIO"
},
"fixedPosition": {
"description": "Don't report GPS position, but a manually-specified one",
@@ -311,35 +367,35 @@
},
"gpsMode": {
"description": "Configure whether device GPS is Enabled, Disabled, or Not Present",
"label": "GPS Mode"
"label": "GPS Mode (Physical Hardware)"
},
"gpsUpdateInterval": {
"description": "How often a GPS fix should be acquired",
"label": "GPS Update Interval"
"description": "How often should we try to get a GPS position (<10sec keeps GPS on).",
"label": "GPS Polling Interval"
},
"positionFlags": {
"description": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.",
"description": "Optional fields to include when assembling position messages. The more fields are included, the larger the message will be leading to longer airtime and a higher risk of packet loss.",
"label": "Position Flags"
},
"receivePin": {
"description": "GPS module RX pin override",
"label": "Receive Pin"
"description": "GPS receive GPIO pin override",
"label": "GPS Receive GPIO"
},
"smartPositionEnabled": {
"description": "Only send position when there has been a meaningful change in location",
"label": "Enable Smart Position"
"label": "Smart Position"
},
"smartPositionMinDistance": {
"description": "Minimum distance (in meters) that must be traveled before a position update is sent",
"label": "Smart Position Minimum Distance"
"description": "The minimum distance change in meters to be considered for a smart position broadcast.",
"label": "Smart Distance"
},
"smartPositionMinInterval": {
"description": "Minimum interval (in seconds) that must pass before a position update is sent",
"label": "Smart Position Minimum Interval"
"description": "The fastest that position updates will be sent if the minimum distance has been satisfied.",
"label": "Smart Interval"
},
"transmitPin": {
"description": "GPS module TX pin override",
"label": "Transmit Pin"
"description": "GPS transmit GPIO pin override",
"label": "GPS Transmit GPIO"
},
"intervalsSettings": {
"description": "How often to send position updates",
@@ -358,16 +414,33 @@
"unset": "Unset",
"vehicleHeading": "Vehicle heading",
"vehicleSpeed": "Vehicle speed"
},
"positionPacket": {
"label": "Position Packet",
"description": "Position broadcast configuration"
},
"deviceGps": {
"label": "Device GPS",
"description": "Device GPS configuration"
},
"advancedDeviceGps": {
"label": "Advanced Device GPS",
"description": "GPIO pin overrides for the GPS hardware"
},
"useBrowserLocation": {
"label": "Use browser location",
"busy": "Reading location…",
"failed": "Could not read browser location"
}
},
"power": {
"adcMultiplierOverride": {
"description": "Used for tweaking battery voltage reading",
"label": "ADC Multiplier Override ratio"
"label": "ADC multiplier override ratio"
},
"ina219Address": {
"description": "Address of the INA219 battery monitor",
"label": "INA219 Address"
"label": "Battery INA_2XX I2C address"
},
"lightSleepDuration": {
"description": "How long the device will be in light sleep for",
@@ -375,11 +448,11 @@
},
"minimumWakeTime": {
"description": "Minimum amount of time the device will stay awake for after receiving a packet",
"label": "Minimum Wake Time"
"label": "Minimum wake time"
},
"noConnectionBluetoothDisabled": {
"description": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long",
"label": "No Connection Bluetooth Disabled"
"label": "Wait for Bluetooth duration"
},
"powerSavingEnabled": {
"description": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.",
@@ -387,11 +460,11 @@
},
"shutdownOnBatteryDelay": {
"description": "Automatically shutdown node after this long when on battery, 0 for indefinite",
"label": "Shutdown on battery delay"
"label": "Shutdown on power loss"
},
"superDeepSleepDuration": {
"description": "How long the device will be in super deep sleep for",
"label": "Super Deep Sleep Duration"
"label": "Super deep sleep duration"
},
"powerConfigSettings": {
"description": "Settings for the power module",
@@ -400,23 +473,27 @@
"sleepSettings": {
"description": "Sleep settings for the power module",
"label": "Sleep Settings"
},
"powerConfig": {
"label": "Power Config",
"description": "Power and sleep configuration"
}
},
"security": {
"description": "Settings for the Security configuration",
"title": "Security Settings",
"title": "Security",
"button_backupKey": "Backup Key",
"adminChannelEnabled": {
"description": "Allow incoming device control over the insecure legacy admin channel",
"label": "Allow Legacy Admin"
"label": "Legacy Admin channel"
},
"enableDebugLogApi": {
"description": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
"label": "Enable Debug Log API"
"label": "Debug log API enabled"
},
"managed": {
"description": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.",
"label": "Managed"
"label": "Managed Mode"
},
"privateKey": {
"description": "Used to create a shared key with a remote device",
@@ -436,7 +513,7 @@
},
"serialOutputEnabled": {
"description": "Serial Console over the Stream API",
"label": "Serial Output Enabled"
"label": "Serial console"
},
"tertiaryAdminKey": {
"description": "The tertiary public key authorized to send admin messages to this node",
@@ -449,10 +526,26 @@
"loggingSettings": {
"description": "Settings for Logging",
"label": "Logging Settings"
},
"directMessageKey": {
"label": "Direct Message Key",
"description": "Public + private direct-message keys"
},
"adminKeysCard": {
"label": "Admin Keys",
"description": "Admin authentication keys"
},
"logsCard": {
"label": "Logs",
"description": "Logging configuration"
},
"administration": {
"label": "Administration",
"description": "Managed-mode and legacy admin channel"
}
},
"user": {
"title": "User Settings",
"title": "User",
"description": "Configure your device name and identity settings",
"longName": {
"label": "Long Name",
@@ -472,11 +565,23 @@
},
"isUnmessageable": {
"label": "Unmessageable",
"description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond."
"description": "Unmonitored or Infrastructure"
},
"isLicensed": {
"label": "Licensed amateur radio (HAM)",
"description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network."
"label": "Licensed amateur radio (Ham)",
"description": "Enabling this option disables encryption and is not compatible with the default Meshtastic network."
},
"userConfig": {
"label": "User Config",
"description": "Identity and licensing"
},
"nodeId": {
"label": "Node ID",
"description": "Read-only — assigned by the device"
},
"hardwareModel": {
"label": "Hardware model",
"description": "Read-only — reported by the device"
}
}
}

View File

@@ -15,6 +15,26 @@
},
"lastConnectedAt": "Last connected: {{date}}",
"neverConnected": "Never connected",
"default": "Default",
"button": {
"addConnection": "Add Connection",
"connect": "Connect",
"disconnect": "Disconnect",
"retry": "Retry",
"delete": "Delete",
"cancel": "Cancel",
"setDefault": "Set as default",
"unsetDefault": "Unset default",
"rename": "Rename",
"edit": "Edit"
},
"renameConnection": "Rename connection",
"renameDescription": "Enter a new name for this connection.",
"nameLabel": "Name",
"editConnection": {
"title": "Edit Connection",
"description": "Modify the settings for this connection."
},
"toasts": {
"connected": "Connected",
"nowConnected": "{{name}} is now connected",
@@ -29,6 +49,25 @@
"pickConnectionAgain": "Could not connect. You may need to reselect the device/port.",
"added": "Connection added",
"savedByName": "{{name}} saved.",
"savedCantConnect": "The connection was saved but could not connect."
"savedCantConnect": "The connection was saved but could not connect.",
"renamed": "Renamed",
"renamedByName": "Connection renamed to {{name}}.",
"updated": "Updated",
"updatedByName": "{{name}} has been updated."
},
"overlay": {
"srTitle": "Connecting to device",
"phase": {
"connecting": "Opening transport…",
"configuring": "Streaming configuration…"
},
"chip": {
"identity": "Identity",
"metadata": "Metadata",
"channels": "Channels",
"nodes": "Nodes"
},
"stuckHint": "Taking longer than usual. The device may be in CLI / bootloader mode, or the firmware isn't responding to config requests.",
"cancel": "Cancel"
}
}

View File

@@ -148,6 +148,11 @@
"remindNever": "Never remind me",
"backupNow": "Back up now"
},
"regionSetup": {
"title": "Set your region",
"description": "This device has no LoRa region configured and won't transmit until you pick one.",
"cta": "Set region"
},
"pkiRegenerate": {
"description": "Are you sure you want to regenerate key pair?",
"title": "Regenerate Key Pair"

View File

@@ -39,6 +39,10 @@
"blue": {
"label": "Blue",
"description": "Sets the blue LED level. Values are 0-255"
},
"ambientLightingConfig": {
"label": "Ambient Lighting Config",
"description": "Ambient Lighting configuration"
}
},
"audio": {
@@ -71,6 +75,10 @@
"i2sSck": {
"label": "i2S SCK",
"description": "GPIO pin to use for i2S SCK"
},
"audioConfig": {
"label": "Audio Config",
"description": "Audio configuration"
}
},
"cannedMessage": {
@@ -119,6 +127,14 @@
"sendBell": {
"label": "Send Bell",
"description": "Sends a bell character with each message"
},
"cannedMessageConfig": {
"label": "Canned Message Config",
"description": "Canned Message configuration"
},
"enabled": {
"label": "Canned message enabled",
"description": "Enable Canned Message"
}
},
"detectionSensor": {
@@ -155,6 +171,10 @@
"usePullup": {
"label": "Use Pullup",
"description": "Whether or not use INPUT_PULLUP mode for GPIO pin"
},
"detectionSensorConfig": {
"label": "Detection Sensor Config",
"description": "Detection Sensor configuration"
}
},
"externalNotification": {
@@ -219,6 +239,22 @@
"useI2sAsBuzzer": {
"label": "Use I²S Pin as Buzzer",
"description": "Designate I²S Pin as Buzzer Output"
},
"externalNotificationConfig": {
"label": "External Notification Config",
"description": "External notification configuration"
},
"notificationsOnMessage": {
"label": "Notifications on message receipt",
"description": "Alerts on incoming text messages"
},
"notificationsOnAlert": {
"label": "Notifications on alert/bell receipt",
"description": "Alerts on incoming bell/alert packets"
},
"advanced": {
"label": "Advanced",
"description": "Pin assignments and timing"
}
},
"mqtt": {
@@ -294,7 +330,21 @@
"imperial_ft300": "Within 300 feet",
"imperial_ft150": "Within 150 feet"
}
},
"shouldReportLocation": {
"label": "I agree.",
"description": "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT.",
"consentHeader": "Consent to Share Unencrypted Node Data via MQTT",
"consentText": "By enabling this feature, you acknowledge and expressly consent to the transmission of your device's real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions."
}
},
"mqttConfigCard": {
"label": "MQTT Config",
"description": "MQTT broker connection"
},
"mapReportingCard": {
"label": "Map reporting",
"description": "Periodic unencrypted map report"
}
},
"neighborInfo": {
@@ -307,6 +357,14 @@
"updateInterval": {
"label": "Update Interval",
"description": "Interval in seconds of how often we should try to send our Neighbor Info to the mesh"
},
"neighborInfoConfig": {
"label": "Neighbor Info Config",
"description": "Neighbor Info configuration"
},
"transmitOverLora": {
"label": "Transmit over LoRa",
"description": "Whether NeighborInfo should be transmitted over LoRa. Not available on a channel with default key and name."
}
},
"paxcounter": {
@@ -327,6 +385,10 @@
"bleThreshold": {
"label": "BLE RSSI Threshold",
"description": "At what BLE RSSI level should the counter increase. Defaults to -80."
},
"paxcounterConfig": {
"label": "Paxcounter Config",
"description": "Paxcounter configuration"
}
},
"rangeTest": {
@@ -343,6 +405,10 @@
"save": {
"label": "Save CSV to storage",
"description": "ESP32 Only"
},
"rangeTestConfig": {
"label": "Range Test Config",
"description": "Range Test configuration"
}
},
"serial": {
@@ -379,6 +445,10 @@
"overrideConsoleSerialPort": {
"label": "Override Console Serial Port",
"description": "If you have a serial port connected to the console, this will override it."
},
"serialConfig": {
"label": "Serial Config",
"description": "Serial module configuration"
}
},
"storeForward": {
@@ -403,6 +473,14 @@
"historyReturnWindow": {
"label": "History return window",
"description": "Return records from this time window (minutes)"
},
"storeForwardConfig": {
"label": "Store & Forward Config",
"description": "Store & Forward configuration"
},
"isServer": {
"label": "Server",
"description": "Run as a Store & Forward server"
}
},
"telemetry": {
@@ -451,6 +529,14 @@
"airQualityScreenEnabled": {
"label": "Air Quality Screen Enabled",
"description": "Show the Air Quality telemetry on the device screen"
},
"telemetryConfig": {
"label": "Telemetry Config",
"description": "Telemetry configuration"
},
"deviceTelemetryEnabled": {
"label": "Send Device Telemetry",
"description": "Enable/Disable the device telemetry module to send metrics to the mesh."
}
},
"remoteHardware": {

View File

@@ -54,28 +54,62 @@
"megahertz": "МГц",
"kilohertz": "кГц",
"raw": "сырое",
"meter": { "one": "метр", "plural": "метров", "suffix": "м" },
"kilometer": { "one": "километр", "plural": "километров", "suffix": "км" },
"minute": { "one": "минута", "plural": инут" },
"hour": { "one": "час", "plural": "часов" },
"meter": {
"one": "метр",
"plural": етров",
"suffix": "м"
},
"kilometer": {
"one": "километр",
"plural": "километров",
"suffix": "км"
},
"minute": {
"one": "минута",
"plural": "минут"
},
"hour": {
"one": "час",
"plural": "часов"
},
"millisecond": {
"one": "миллисекунда",
"plural": "миллисекунд",
"suffix": "мс"
},
"second": { "one": "секунда", "plural": "секунд" },
"second": {
"one": "секунда",
"plural": "секунд"
},
"day": {
"one": "день",
"plural": "дней",
"today": "Сегодня",
"yesterday": "Вчера"
},
"month": { "one": "месяц", "plural": "месяцев" },
"year": { "one": "год", "plural": "лет" },
"month": {
"one": "месяц",
"plural": "месяцев"
},
"year": {
"one": "год",
"plural": "лет"
},
"snr": "SNR",
"volt": { "one": "вольт", "plural": "вольт", "suffix": "В" },
"record": { "one": "запись", "plural": "записей" },
"degree": { "one": "градус", "plural": "градусов", "suffix": "°" }
"volt": {
"one": "вольт",
"plural": "вольт",
"suffix": "В"
},
"record": {
"one": "запись",
"plural": "записей"
},
"degree": {
"one": "градус",
"plural": "градусов",
"suffix": "°"
}
},
"security": {
"0bit": "Пусто",

View File

@@ -29,6 +29,7 @@
"pickConnectionAgain": "Не удалось подключиться. Возможно, нужно заново выбрать устройство/порт.",
"added": "Подключение добавлено",
"savedByName": "{{name}} сохранено.",
"savedCantConnect": "Подключение сохранено, но подключиться не удалось."
"savedCantConnect": "Подключение сохранено, но подключиться не удалось.",
"checkConnetion": "Check your device or settings and try again"
}
}

View File

@@ -42,14 +42,28 @@
"commandPalette": "Поиск команд..."
},
"toast": {
"positionRequestSent": { "title": "Запрос позиции отправлен." },
"requestingPosition": { "title": "Запрашиваем позицию, пожалуйста, подождите..." },
"sendingTraceroute": { "title": "Отправляем трассировку, пожалуйста, подождите..." },
"tracerouteSent": { "title": "Трассировка отправлена." },
"savedChannel": { "title": "Канал сохранён: {{channelName}}" },
"positionRequestSent": {
"title": "Запрос позиции отправлен."
},
"requestingPosition": {
"title": "Запрашиваем позицию, пожалуйста, подождите..."
},
"sendingTraceroute": {
"title": "Отправляем трассировку, пожалуйста, подождите..."
},
"tracerouteSent": {
"title": "Трассировка отправлена."
},
"savedChannel": {
"title": "Канал сохранён: {{channelName}}"
},
"messages": {
"pkiEncryption": { "title": "Чат использует шифрование PKI." },
"pskEncryption": { "title": "Чат использует шифрование PSK." }
"pkiEncryption": {
"title": "Чат использует шифрование PKI."
},
"pskEncryption": {
"title": "Чат использует шифрование PSK."
}
},
"configSaveError": {
"title": "Ошибка сохранения настроек",
@@ -212,5 +226,9 @@
"footer": {
"text": "Работает на <0>▲ Vercel</0> | Meshtastic® — зарегистрированный товарный знак Meshtastic LLC. | <1>Юридическая информация</1>",
"commitSha": "SHA коммита: {{sha}}"
},
"language": {
"label": "Язык",
"changeLanguage": "Change Language"
}
}

View File

@@ -1,7 +1,9 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { ConnectingOverlay } from "@components/ConnectingOverlay.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { RegionSetupReminder } from "@components/RegionSetupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
@@ -22,17 +24,14 @@ export function App() {
const device = getDevice(selectedDeviceId);
return (
// <ThemeProvider defaultTheme="system" storageKey="theme">
<ErrorBoundary FallbackComponent={ErrorPage}>
{/* <NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
/> */}
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}>
{/* Overlay sits 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. */}
<ConnectingOverlay />
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
style={{ scrollbarWidth: "thin" }}
@@ -43,6 +42,7 @@ export function App() {
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<RegionSetupReminder />
<CommandPalette />
<MapProvider>
<Outlet />

View File

@@ -8,7 +8,8 @@ import {
CommandList,
} from "@components/UI/Command.tsx";
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
import { useAppStore, useDevice, useDeviceStore, useNodeDB } from "@core/stores";
import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useAppStore, useDevice, useDeviceStore } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useNavigate } from "@tanstack/react-router";
import { useCommandState } from "cmdk";
@@ -61,7 +62,8 @@ export const CommandPalette = () => {
useAppStore();
const { getDevices } = useDeviceStore();
const { setDialogOpen, connection } = useDevice();
const { getNode } = useNodeDB();
const allNodes = useNodesAsProto();
const getNode = (n: number) => allNodes.find((node) => node.num === n);
const { pinnedItems, togglePinnedItem } = usePinnedItems({
storageName: "pinnedCommandMenuGroups",
});

View File

@@ -0,0 +1,174 @@
import { Dialog, DialogContent, DialogTitle } from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button.tsx";
import { useDeviceStore } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useConnections } from "@app/pages/Connections/useConnections";
import { useConnectionProgress } from "@meshtastic/sdk-react";
import { Bluetooth, Cable, CheckCircle2, Globe, Loader2 } from "lucide-react";
import type { ComponentType, ReactElement } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
const STUCK_THRESHOLD_MS = 15_000;
const TRANSPORT_ICON: Record<
"http" | "bluetooth" | "serial",
ComponentType<{ className?: string }>
> = {
http: Globe,
bluetooth: Bluetooth,
serial: Cable,
};
/**
* Hero-card connect overlay. Visible while any saved connection's status
* is "connecting" or "configuring" — driven from the saved-connection
* status field rather than `device.connectionPhase` so the overlay shows
* during transport-open + storage-open (the device entry doesn't exist
* yet during those phases).
*
* Center: device name + transport icon with an animated radial pulse.
* Surrounding: 4 stat chips (Identity / Channels / Nodes / Metadata)
* that flip from gray to green as the SDK reports each piece arriving.
* Auto-dismisses on "configured" (with a brief success state) or
* "disconnected" / "error".
*/
export const ConnectingOverlay = (): ReactElement | null => {
const savedConnections = useDeviceStore((s) => s.savedConnections);
const progress = useConnectionProgress();
const { disconnect } = useConnections();
const { t } = useTranslation("connections");
const active = savedConnections.find(
(c) => c.status === "connecting" || c.status === "configuring",
);
// Stuck-detection: once an attempt has been visible for STUCK_THRESHOLD_MS,
// surface a Cancel button so the user can bail out of the overlay even when
// `onConfigComplete` never arrives (firmware in CLI / bootloader, framing
// out of sync, etc.). Resets when a fresh attempt starts.
const [showCancel, setShowCancel] = useState(false);
useEffect(() => {
if (!active) {
setShowCancel(false);
return;
}
setShowCancel(false);
const t = setTimeout(() => setShowCancel(true), STUCK_THRESHOLD_MS);
return () => clearTimeout(t);
}, [active?.id]);
if (!active) return null;
const Icon = TRANSPORT_ICON[active.type];
const counters =
progress.phase === "configuring" || progress.phase === "configured"
? progress.received
: { config: 0, modules: 0, channels: 0, nodes: 0, myInfo: false, metadata: false };
const isConnecting = active.status === "connecting";
const phaseLabel = isConnecting
? t("overlay.phase.connecting", { defaultValue: "Opening transport…" })
: t("overlay.phase.configuring", { defaultValue: "Streaming configuration…" });
return (
<Dialog open>
<DialogContent
className="max-w-sm overflow-hidden bg-background-primary p-0 text-text-primary shadow-2xl"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">
{t("overlay.srTitle", { defaultValue: "Connecting to device" })}
</DialogTitle>
<div className="flex flex-col items-center gap-6 px-8 pt-10 pb-8">
{/* Hero icon with radial pings — uses the theme link color so light
and dark modes both look at-home. */}
<div className="relative flex h-28 w-28 items-center justify-center">
<span className="absolute inset-0 animate-ping rounded-full bg-link/20 [animation-duration:1.8s]" />
<span className="absolute inset-3 animate-ping rounded-full bg-link/30 [animation-duration:2.4s] [animation-delay:0.4s]" />
<span className="absolute inset-6 rounded-full bg-link shadow-lg shadow-link/40" />
<Icon className="relative h-9 w-9 text-background-primary" />
</div>
{/* Name + phase */}
<div className="text-center">
<div className="text-lg font-semibold tracking-tight text-text-primary">
{active.name}
</div>
<div className="mt-1 inline-flex items-center gap-1.5 text-xs text-text-secondary">
<Loader2 className="h-3 w-3 animate-spin" />
{phaseLabel}
</div>
</div>
{/* Stat chips — 2x2 grid */}
<div className="grid w-full grid-cols-2 gap-2">
<Chip
label={t("overlay.chip.identity", { defaultValue: "Identity" })}
done={counters.myInfo}
/>
<Chip
label={t("overlay.chip.metadata", { defaultValue: "Metadata" })}
done={counters.metadata}
/>
<Chip
label={t("overlay.chip.channels", { defaultValue: "Channels" })}
count={counters.channels}
done={counters.channels > 0 && progress.phase === "configured"}
/>
<Chip
label={t("overlay.chip.nodes", { defaultValue: "Nodes" })}
count={counters.nodes}
done={counters.nodes > 0 && progress.phase === "configured"}
/>
</div>
{showCancel && (
<div className="flex w-full flex-col items-center gap-2">
<p className="text-center text-xs text-text-secondary">
{t("overlay.stuckHint", {
defaultValue:
"Taking longer than usual. The device may be in CLI / bootloader mode, or the firmware isn't responding to config requests.",
})}
</p>
<Button type="button" variant="outline" onClick={() => void disconnect(active.id)}>
{t("overlay.cancel", { defaultValue: "Cancel" })}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
interface ChipProps {
label: string;
count?: number;
done: boolean;
}
function Chip({ label, count, done }: ChipProps): ReactElement {
const filled = count !== undefined ? count > 0 : done;
return (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
filled
? "border-green-500/40 bg-green-500/10 text-green-700 dark:text-green-300"
: "border-text-secondary/30 bg-text-secondary/5 text-text-secondary",
)}
>
<span className="font-medium">{label}</span>
{count !== undefined ? (
<span className="font-mono text-xs tabular-nums">{count}</span>
) : done ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<span className="h-4 w-4 rounded-full border border-dashed border-text-secondary/50" />
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
import { cn } from "@core/utils/cn.ts";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import { useNavigate } from "@tanstack/react-router";
import {
ChevronRight,

View File

@@ -1,33 +1,25 @@
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
import { type MessageStore, useMessages } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useMessages: vi.fn(() => ({
deleteAllMessages: vi.fn(),
const mockClearAll = vi.fn();
vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: vi.fn(() => ({
chat: { clearAll: mockClearAll },
})),
}));
describe("DeleteMessagesDialog", () => {
const mockOnOpenChange = vi.fn();
const mockClearAllMessages = vi.fn();
beforeEach(() => {
mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear();
const mockedUseMessages = vi.mocked(useMessages);
mockedUseMessages.mockImplementation(
() =>
({
deleteAllMessages: mockClearAllMessages,
}) as unknown as MessageStore,
);
mockedUseMessages.mockClear();
mockClearAll.mockClear();
vi.mocked(useActiveClient).mockReturnValue({
chat: { clearAll: mockClearAll },
} as never);
});
it("calls onOpenChange with false when the close button (X) is clicked", () => {
@@ -59,15 +51,23 @@ describe("DeleteMessagesDialog", () => {
it("calls onOpenChange with false when the dismiss button is clicked", () => {
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("calls deleteAllMessages and onOpenChange with false when the clear messages button is clicked", () => {
it("calls chat.clearAll and onOpenChange with false when Clear Messages is clicked", () => {
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockClearAll).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it("no-ops gracefully when there is no active client", () => {
vi.mocked(useActiveClient).mockReturnValue(undefined);
render(<DeleteMessagesDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Clear Messages" }));
expect(mockClearAll).not.toHaveBeenCalled();
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,4 +1,4 @@
import { useMessages } from "@app/core/stores/index.ts";
import { useActiveClient } from "@meshtastic/sdk-react";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
@@ -10,10 +10,10 @@ export interface DeleteMessagesDialogProps {
export const DeleteMessagesDialog = ({ open, onOpenChange }: DeleteMessagesDialogProps) => {
const { t } = useTranslation("dialog");
const messageStore = useMessages();
const meshClient = useActiveClient();
const handleConfirm = () => {
messageStore.deleteAllMessages();
void meshClient?.chat.clearAll();
onOpenChange(false);
};

View File

@@ -16,7 +16,7 @@ import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRo
import { useDevice } from "@core/stores";
export const DialogManager = () => {
const { channels, config, dialog, setDialogOpen } = useDevice();
const { config, dialog, setDialogOpen } = useDevice();
return (
<>
<QRDialog
@@ -24,7 +24,6 @@ export const DialogManager = () => {
onOpenChange={(open) => {
setDialogOpen("QR", open);
}}
channels={channels}
loraConfig={config.lora}
/>
<ImportDialog

View File

@@ -4,21 +4,12 @@ import { FactoryResetDeviceDialog } from "./FactoryResetDeviceDialog.tsx";
const mockFactoryResetDevice = vi.fn();
const mockRemoveDevice = vi.fn();
const mockRemoveMessageStore = vi.fn();
const mockRemoveNodeDB = vi.fn();
const mockToast = vi.fn();
vi.mock("@core/stores", () => {
// Make each store a callable fn (like a Zustand hook), and attach .getState()
const useDeviceStore = Object.assign(vi.fn(), {
getState: () => ({ removeDevice: mockRemoveDevice }),
});
const useMessageStore = Object.assign(vi.fn(), {
getState: () => ({ removeMessageStore: mockRemoveMessageStore }),
});
const useNodeDBStore = Object.assign(vi.fn(), {
getState: () => ({ removeNodeDB: mockRemoveNodeDB }),
});
return {
CurrentDeviceContext: {
@@ -29,8 +20,6 @@ vi.mock("@core/stores", () => {
connection: { factoryResetDevice: mockFactoryResetDevice },
}),
useDeviceStore,
useMessageStore,
useNodeDBStore,
};
});
@@ -45,13 +34,10 @@ describe("FactoryResetDeviceDialog", () => {
mockOnOpenChange.mockClear();
mockFactoryResetDevice.mockReset();
mockRemoveDevice.mockClear();
mockRemoveMessageStore.mockClear();
mockRemoveNodeDB.mockClear();
mockToast.mockClear();
});
it("calls factoryResetDevice, closes dialog, and after reset resolves clears messages and node DB", async () => {
// Control the promise returned by factoryResetDevice
it("calls factoryResetDevice, closes dialog, and after reset resolves removes the device", async () => {
let resolveReset: (() => void) | undefined;
mockFactoryResetDevice.mockImplementation(
() =>
@@ -63,21 +49,16 @@ describe("FactoryResetDeviceDialog", () => {
render(<FactoryResetDeviceDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Factory Reset Device" }));
// Called immediately
expect(mockFactoryResetDevice).toHaveBeenCalledTimes(1);
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
// Resolve the reset
resolveReset?.();
expect(mockRemoveDevice).toHaveBeenCalledTimes(1);
expect(mockRemoveMessageStore).toHaveBeenCalledTimes(1);
expect(mockRemoveNodeDB).toHaveBeenCalledTimes(1);
});
it("calls onOpenChange(false) and does not call factoryResetDevice when cancel is clicked", async () => {
@@ -91,7 +72,5 @@ describe("FactoryResetDeviceDialog", () => {
expect(mockFactoryResetDevice).not.toHaveBeenCalled();
expect(mockRemoveDevice).not.toHaveBeenCalled();
expect(mockRemoveMessageStore).not.toHaveBeenCalled();
expect(mockRemoveNodeDB).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { toast } from "@core/hooks/useToast.ts";
import { useDevice, useDeviceStore, useMessageStore, useNodeDBStore } from "@core/stores";
import { useDevice, useDeviceStore } from "@core/stores";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
@@ -23,8 +23,6 @@ export const FactoryResetDeviceDialog = ({ open, onOpenChange }: FactoryResetDev
// The device will be wiped and disconnected without resolving the promise
// so we proceed to clear all data associated with the device immediately
useDeviceStore.getState().removeDevice(id);
useMessageStore.getState().removeMessageStore(id);
useNodeDBStore.getState().removeNodeDB(id);
// Reload the app to ensure all ephemeral state is cleared
window.location.href = "/";

View File

@@ -20,8 +20,8 @@ import {
} from "@components/UI/Select.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor } from "@meshtastic/sdk-react";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -33,7 +33,8 @@ export interface ImportDialogProps {
}
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { setChange, channels, config } = useDevice();
const { config } = useDevice();
const editor = useConfigEditor();
const { t } = useTranslation("dialog");
const [importDialogInput, setImportDialogInput] = useState<string>("");
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
@@ -77,6 +78,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
}, [importDialogInput, t]);
const apply = () => {
if (!editor) return;
channelSet?.settings.forEach((ch: Protobuf.Channel.ChannelSettings, index: number) => {
if (importIndex[index] === -1) {
return;
@@ -91,24 +93,15 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
settings: ch,
});
if (!deepCompareConfig(channels.get(importIndex[index] ?? 0), payload, true)) {
setChange(
{ type: "channel", index: importIndex[index] ?? 0 },
payload,
channels.get(importIndex[index] ?? 0),
);
}
editor.setChannel(payload);
});
if (channelSet?.loraConfig && updateConfig) {
const payload = {
...config.lora,
...channelSet.loraConfig,
};
if (!deepCompareConfig(config.lora, payload, true)) {
setChange({ type: "config", variant: "lora" }, payload, config.lora);
}
} as Protobuf.Config.Config_LoRaConfig;
editor.setRadioSection("lora", payload);
}
// Reset state after import
setImportDialogInput("");

View File

@@ -1,5 +1,5 @@
import { useNodeDB } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import type { Protobuf, Types } from "@meshtastic/sdk";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
import {
@@ -23,9 +23,7 @@ export const LocationResponseDialog = ({
onOpenChange,
}: LocationResponseDialogProps) => {
const { t } = useTranslation("dialog");
const { getNode } = useNodeDB();
const from = getNode(location?.from ?? 0);
const from = useNodeAsProto(location?.from ?? 0);
const longName =
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const shortName =

View File

@@ -1,169 +0,0 @@
import { BLE } from "@components/PageComponents/Connect/BLE.tsx";
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { Serial } from "@components/PageComponents/Connect/Serial.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
import {
type BrowserFeature,
useBrowserFeatureDetection,
} from "@core/hooks/useBrowserFeatureDetection.ts";
import { AlertCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "../UI/Typography/Link.tsx";
export interface TabElementProps {
closeDialog: () => void;
}
export interface TabManifest {
id: "HTTP" | "BLE" | "Serial";
label: string;
element: React.FC<TabElementProps>;
isDisabled: boolean;
}
export interface NewDeviceProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface FeatureErrorProps {
missingFeatures: BrowserFeature[];
tabId: "HTTP" | "BLE" | "Serial";
}
const errors: Record<BrowserFeature, { href: string; i18nKey: string }> = {
"Web Bluetooth": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebBluetooth",
},
"Web Serial": {
href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
i18nKey: "newDeviceDialog.validation.requiresWebSerial",
},
"Secure Context": {
href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
i18nKey: "newDeviceDialog.validation.requiresSecureContext",
},
};
const ErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
if (missingFeatures.length === 0) {
return null;
}
const browserFeatures = missingFeatures.filter((feature) => feature !== "Secure Context");
const needsSecureContext = missingFeatures.includes("Secure Context");
const needsFeature =
tabId === "BLE" && browserFeatures.includes("Web Bluetooth")
? "Web Bluetooth"
: tabId === "Serial" && browserFeatures.includes("Web Serial")
? "Web Serial"
: undefined;
return (
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 w-full">
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
<div className="flex flex-col gap-3">
<div className="text-sm text-white">
{needsFeature && (
<Trans
i18nKey={errors[needsFeature].i18nKey}
components={[
<Link
key="0"
href={errors[needsFeature].href}
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
/>,
]}
/>
)}
{needsFeature && needsSecureContext && " "}
{needsSecureContext && (
<Trans
i18nKey={
browserFeatures.length > 0
? "newDeviceDialog.validation.additionallyRequiresSecureContext"
: "newDeviceDialog.validation.requiresSecureContext"
}
components={{
"0": (
<Link
href={errors["Secure Context"].href}
className="underline hover:text-slate-200"
/>
),
}}
/>
)}
</div>
</div>
</div>
</div>
);
};
export const NewDeviceDialog = ({ open, onOpenChange }: NewDeviceProps) => {
const { t } = useTranslation("dialog");
const { unsupported } = useBrowserFeatureDetection();
const tabs: TabManifest[] = [
{
id: "HTTP",
label: t("newDeviceDialog.tabHttp"),
element: HTTP,
isDisabled: false,
},
{
id: "BLE",
label: t("newDeviceDialog.tabBluetooth"),
element: BLE,
isDisabled: unsupported.includes("Web Bluetooth") || unsupported.includes("Secure Context"),
},
{
id: "Serial",
label: t("newDeviceDialog.tabSerial"),
element: Serial,
isDisabled: unsupported.includes("Web Serial") || unsupported.includes("Secure Context"),
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("newDeviceDialog.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="HTTP">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<fieldset disabled={tab.isDisabled}>
{tab.id !== "HTTP" && tab.isDisabled ? (
<ErrorMessage missingFeatures={unsupported} tabId={tab.id} />
) : (
<tab.element closeDialog={() => onOpenChange(false)} />
)}
</fieldset>
</TabsContent>
))}
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -27,9 +27,10 @@ import {
import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts";
import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts";
import { toast } from "@core/hooks/useToast.ts";
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useAppStore, useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useNavigate } from "@tanstack/react-router";
import { fromByteArray } from "base64-js";
@@ -54,13 +55,12 @@ export interface NodeDetailsDialogProps {
export const NodeDetailsDialog = ({ open, onOpenChange }: NodeDetailsDialogProps) => {
const { t } = useTranslation("dialog");
const { setDialogOpen, connection } = useDevice();
const { getNode } = useNodeDB();
const navigate = useNavigate();
const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore();
const { updateFavorite } = useFavoriteNode();
const { updateIgnored } = useIgnoreNode();
const node = getNode(nodeNumDetails);
const node = useNodeAsProto(nodeNumDetails);
const [isFavoriteState, setIsFavoriteState] = useState<boolean>(node?.isFavorite ?? false);
const [isIgnoredState, setIsIgnoredState] = useState<boolean>(node?.isIgnored ?? false);

View File

@@ -8,7 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useDevice, useNodeDB } from "@core/stores";
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useDevice } from "@core/stores";
import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react";
@@ -22,7 +23,7 @@ export interface PkiBackupDialogProps {
export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) => {
const { t } = useTranslation("dialog");
const { config, setDialogOpen } = useDevice();
const { getMyNode } = useNodeDB();
const myNode = useMyNodeAsProto();
const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
@@ -49,8 +50,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
<head>
<title>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
longName: myNode?.user?.longName ?? t("unknown.longName"),
})}</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
@@ -61,8 +62,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
<body>
<h1>${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
longName: myNode?.user?.longName ?? t("unknown.longName"),
})}</h1>
<h3>${t("pkiBackup.secureBackup")}</h3>
<h3>${t("pkiBackup.publicKey")}</h3>
@@ -77,7 +78,7 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
printWindow.print();
closeDialog();
}
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, myNode]);
const createDownloadKeyFile = React.useCallback(() => {
if (!privateKey || !publicKey) {
@@ -90,8 +91,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
const formattedContent = [
`${t("pkiBackup.header", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
longName: myNode?.user?.longName ?? t("unknown.longName"),
})}\n\n`,
`${t("pkiBackup.privateKey")}\n`,
decodedPrivateKey,
@@ -107,8 +108,8 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
link.href = url;
link.download = t("pkiBackup.fileName", {
interpolation: { escapeValue: false },
shortName: getMyNode()?.user?.shortName ?? t("unknown.shortName"),
longName: getMyNode()?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
longName: myNode?.user?.longName ?? t("unknown.longName"),
});
link.style.display = "none";
@@ -117,7 +118,7 @@ export const PkiBackupDialog = ({ open, onOpenChange }: PkiBackupDialogProps) =>
document.body.removeChild(link);
closeDialog();
URL.revokeObjectURL(url);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, getMyNode]);
}, [decodeKeyData, privateKey, publicKey, closeDialog, t, myNode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -10,9 +10,10 @@ import {
} from "@components/UI/Dialog.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
import { Protobuf, type Types } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useChannels } from "@meshtastic/sdk-react";
import { fromByteArray } from "base64-js";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { QRCode } from "react-qrcode-logo";
import { Checkbox } from "../UI/Checkbox/index.tsx";
@@ -21,16 +22,15 @@ export interface QRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
loraConfig?: Protobuf.Config.Config_LoRaConfig;
channels: Map<Types.ChannelNumber, Protobuf.Channel.Channel>;
}
export const QRDialog = ({ open, onOpenChange, loraConfig, channels }: QRDialogProps) => {
export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => {
const { t } = useTranslation("dialog");
const [selectedChannels, setSelectedChannels] = useState<number[]>([0]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [qrCodeAdd, setQrCodeAdd] = useState<boolean>();
const allChannels = useMemo(() => Array.from(channels.values()), [channels]);
const allChannels = useChannels();
useEffect(() => {
const channelsToEncode = allChannels

View File

@@ -1,19 +1,13 @@
import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores";
import { CurrentDeviceContext, useDeviceStore } from "@core/stores";
import { MeshRegistry } from "@meshtastic/sdk";
import { MeshRegistryProvider } from "@meshtastic/sdk-react";
import { render } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
vi.mock("@core/stores", async () => {
const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores");
return {
...actual,
useMessageStore: vi.fn(),
};
});
vi.mock("./useRefreshKeysDialog");
const mockUseMessageStore = vi.mocked(useMessageStore);
const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog);
const getInitialState = () =>
@@ -31,22 +25,25 @@ afterEach(() => {
vi.restoreAllMocks();
});
test("does not render dialog if no error exists for active chat", () => {
test("does not render dialog when there are no node errors", () => {
const deviceId = 1;
const activeChatNum = 54321;
useDeviceStore.getState().addDevice(deviceId);
mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum });
mockUseRefreshKeysDialog.mockReturnValue({
handleCloseDialog: vi.fn(),
handleNodeRemove: vi.fn(),
});
// Empty MeshRegistry so useNodeErrors returns the empty fallback.
const registry = new MeshRegistry();
const { container } = render(
<CurrentDeviceContext.Provider value={{ deviceId }}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</CurrentDeviceContext.Provider>,
<MeshRegistryProvider registry={registry}>
<CurrentDeviceContext.Provider value={{ deviceId }}>
<RefreshKeysDialog open onOpenChange={vi.fn()} />
</CurrentDeviceContext.Provider>
</MeshRegistryProvider>,
);
expect(container.firstChild).toBeNull();

View File

@@ -6,7 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { useMessages, useNodeDB } from "@core/stores";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useNodeErrors } from "@meshtastic/sdk-react";
import { LockKeyholeOpenIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
@@ -18,19 +19,15 @@ export interface RefreshKeysDialogProps {
export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps) => {
const { t } = useTranslation("dialog");
const { activeChat } = useMessages();
const { nodeErrors, getNode } = useNodeDB();
const nodeError = useNodeErrors()[0];
const nodeWithError = useNodeAsProto(nodeError?.node ?? 0);
const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog();
const nodeErrorNum = nodeErrors.get(activeChat);
if (!nodeErrorNum) {
if (!nodeError) {
return null;
}
const nodeWithError = getNode(nodeErrorNum.node);
const text = {
title: t("refreshKeys.title", {
interpolation: { escapeValue: false },

View File

@@ -1,24 +1,22 @@
import { useDevice, useMessages, useNodeDB } from "@core/stores";
import { useDevice } from "@core/stores";
import { useActiveClient, useNodeErrors } from "@meshtastic/sdk-react";
import { useCallback } from "react";
export function useRefreshKeysDialog() {
const { setDialogOpen } = useDevice();
const { removeNode, clearNodeError, getNodeError } = useNodeDB();
const { activeChat } = useMessages();
const meshClient = useActiveClient();
const error = useNodeErrors()[0];
const handleCloseDialog = useCallback(() => {
setDialogOpen("refreshKeys", false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) {
return;
}
clearNodeError(activeChat);
if (!meshClient || !error) return;
meshClient.nodes.clearError(error.node);
handleCloseDialog();
return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
void meshClient.nodes.remove(error.node);
}, [meshClient, error, handleCloseDialog]);
return {
handleCloseDialog,

View File

@@ -1,5 +1,7 @@
import { Label } from "@components/UI/Label.tsx";
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useAppStore } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "./DialogWrapper.tsx";
@@ -10,13 +12,12 @@ export interface RemoveNodeDialogProps {
export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const { getNode, removeNode } = useNodeDB();
const meshClient = useActiveClient();
const { nodeNumToBeRemoved } = useAppStore();
const node = useNodeAsProto(nodeNumToBeRemoved);
const handleConfirm = () => {
connection?.removeNodeByNum(nodeNumToBeRemoved);
removeNode(nodeNumToBeRemoved);
void meshClient?.nodes.remove(nodeNumToBeRemoved);
};
return (
@@ -31,7 +32,7 @@ export const RemoveNodeDialog = ({ open, onOpenChange }: RemoveNodeDialogProps)
onConfirm={handleConfirm}
>
<div className="gap-4">
<Label>{getNode(nodeNumToBeRemoved)?.user?.longName}</Label>
<Label>{node?.user?.longName}</Label>
</div>
</DialogWrapper>
);

View File

@@ -1,93 +1,66 @@
// ResetNodeDbDialog.test.tsx
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx";
const mockResetNodes = vi.fn();
const mockDeleteAllMessages = vi.fn();
const mockRemoveAllNodeErrors = vi.fn();
const mockRemoveAllNodes = vi.fn();
const mockClearAll = vi.fn();
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useDevice: () => ({
connection: {
resetNodes: mockResetNodes,
},
}),
useMessages: () => ({
deleteAllMessages: mockDeleteAllMessages,
}),
useNodeDB: () => ({
removeAllNodeErrors: mockRemoveAllNodeErrors,
removeAllNodes: mockRemoveAllNodes,
}),
const { mockUseActiveClient } = vi.hoisted(() => ({
mockUseActiveClient: vi.fn(),
}));
vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
describe("ResetNodeDbDialog", () => {
const mockOnOpenChange = vi.fn();
beforeEach(() => {
mockOnOpenChange.mockClear();
mockResetNodes.mockClear();
mockDeleteAllMessages.mockClear();
mockRemoveAllNodeErrors.mockClear();
mockRemoveAllNodes.mockClear();
vi.clearAllMocks();
mockUseActiveClient.mockReturnValue({
nodes: { reset: mockResetNodes },
chat: { clearAll: mockClearAll },
});
});
it("calls resetNodes, closes dialog, and after resolve clears messages and node DB (with true flag)", async () => {
// Control the promise returned by resetNodes
let resolveReset: (() => void) | undefined;
it("calls reset({ keepMyNode: true }) then clears chat after resolve", async () => {
let resolveReset: ((value: { status: "ok"; value: number }) => void) | undefined;
mockResetNodes.mockImplementation(
() =>
new Promise<void>((resolve) => {
new Promise((resolve) => {
resolveReset = resolve;
}),
);
mockClearAll.mockResolvedValue(undefined);
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Reset Node Database" }));
// Called immediately
expect(mockResetNodes).toHaveBeenCalledTimes(1);
expect(mockResetNodes).toHaveBeenCalledWith({ keepMyNode: true });
// DialogWrapper awaits onConfirm (which returns undefined), so close happens on next microtask
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
// Nothing else should have happened yet (the promise hasn't resolved)
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
expect(mockClearAll).not.toHaveBeenCalled();
// Resolve the reset
resolveReset?.();
resolveReset?.({ status: "ok", value: 1 });
// Now the .then() chain should fire
await waitFor(() => {
expect(mockDeleteAllMessages).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledWith(true);
expect(mockClearAll).toHaveBeenCalledTimes(1);
});
});
it("calls onOpenChange(false) and does not call resetNodes when cancel is clicked", async () => {
it("does not call reset when cancel is clicked", async () => {
render(<ResetNodeDbDialog open onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
expect(mockResetNodes).not.toHaveBeenCalled();
expect(mockDeleteAllMessages).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
expect(mockClearAll).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { toast } from "@core/hooks/useToast.ts";
import { useDevice, useMessages, useNodeDB } from "@core/stores";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
import { DialogWrapper } from "../DialogWrapper.tsx";
@@ -10,22 +10,18 @@ export interface ResetNodeDbDialogProps {
export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps) => {
const { t } = useTranslation("dialog");
const { connection } = useDevice();
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
const { deleteAllMessages } = useMessages();
const meshClient = useActiveClient();
const handleResetNodeDb = () => {
connection
?.resetNodes()
.then(() => {
deleteAllMessages();
removeAllNodeErrors();
removeAllNodes(true);
if (!meshClient) return;
meshClient.nodes
.reset({ keepMyNode: true })
.then((result) => {
if (result.status === "error") throw result.error;
return meshClient.chat.clearAll();
})
.catch((error) => {
toast({
title: t("resetNodeDb.failedTitle"),
});
toast({ title: t("resetNodeDb.failedTitle") });
console.error("Failed to reset Node DB:", error);
});
};

View File

@@ -1,5 +1,5 @@
import { useNodeDB } from "@core/stores";
import type { Protobuf, Types } from "@meshtastic/core";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import type { Protobuf, Types } from "@meshtastic/sdk";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
@@ -25,19 +25,18 @@ export const TracerouteResponseDialog = ({
onOpenChange,
}: TracerouteResponseDialogProps) => {
const { t } = useTranslation("dialog");
const { getNode } = useNodeDB();
const route: number[] = traceroute?.data.route ?? [];
const routeBack: number[] = traceroute?.data.routeBack ?? [];
const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4);
const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4);
const from = getNode(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet
const from = useNodeAsProto(traceroute?.to ?? 0); // The origin of the traceroute = the "to" node of the mesh packet
const fromLongName =
from?.user?.longName ?? (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName"));
const fromShortName =
from?.user?.shortName ??
(from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName"));
const toUser = getNode(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet
const toUser = useNodeAsProto(traceroute?.from ?? 0); // The destination of the traceroute = the "from" node of the mesh packet
if (!toUser || !from) {
return null;

View File

@@ -4,7 +4,7 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useEffect } from "react";
import { type ReactNode, useEffect } from "react";
import {
type Control,
type DefaultValues,
@@ -59,6 +59,9 @@ export interface DynamicFormProps<T extends FieldValues> {
valid?: boolean;
validationText?: string;
fields: FieldProps<T>[];
/** Optional JSX rendered after the field list (e.g. action buttons that
* reach into the form via useFormContext). */
footer?: ReactNode;
}[];
validationSchema?: ZodType<T>;
}
@@ -168,6 +171,7 @@ export function DynamicForm<T extends FieldValues>({
</FieldWrapper>
);
})}
{fieldGroup.footer}
</div>
))}
{hasSubmitButton && (

View File

@@ -1,5 +1,6 @@
import type {
FieldError,
FieldErrors,
FieldValues,
Resolver,
ResolverOptions,
@@ -49,8 +50,8 @@ export function createZodResolver<T extends FieldValues>(
}
return {
values: {} as T,
errors,
values: {} as Record<string, never>,
errors: errors as FieldErrors<T>,
};
};
}

View File

@@ -4,8 +4,8 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -17,8 +17,20 @@ export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
}
const EMPTY_CHANNELS_SIGNAL = {
value: new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<
number,
Protobuf.Channel.Channel
>,
peek: () =>
new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<number, Protobuf.Channel.Channel>,
subscribe: () => () => {},
} as const;
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
const { config, setChange, getChange, removeChange } = useDevice();
const { config } = useDevice();
const editor = useConfigEditor();
const editorChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL);
const { t } = useTranslation(["channels", "ui", "dialog"]);
const defaultConfig = channel;
@@ -39,10 +51,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
},
};
const workingChannel = getChange({
type: "channel",
index: channel.index,
}) as Protobuf.Channel.Channel | undefined;
const workingChannel = editorChannels.get(channel.index);
const effectiveConfig = workingChannel ?? channel;
const formValues = {
...effectiveConfig,
@@ -95,7 +104,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
}, [effectiveByteCount, trigger]);
const onSubmit = (data: ChannelValidation) => {
if (!formState.isReady) {
if (!formState.isReady || !editor) {
return;
}
@@ -111,12 +120,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
},
});
if (deepCompareConfig(channel, payload, true)) {
removeChange({ type: "channel", index: channel.index });
return;
}
setChange({ type: "channel", index: channel.index }, payload, channel);
editor.setChannel(payload);
};
const preSharedKeyRegenerate = async () => {

View File

@@ -1,9 +1,11 @@
import { Channel } from "@app/components/PageComponents/Channels/Channel";
import { create } from "@bufbuild/protobuf";
import { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useChannels, useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { Suspense, useMemo } from "react";
@@ -14,9 +16,9 @@ interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
export const getChannelName = (channel: { index: number; settings?: { name?: string } }) => {
return channel.settings?.name?.length
? channel.settings.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
@@ -25,14 +27,34 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => {
});
};
const EMPTY_DIRTY_CHANNELS_SIGNAL = {
value: [] as readonly number[],
peek: () => [] as readonly number[],
subscribe: () => () => {},
} as const;
export const Channels = ({ onFormInit }: ConfigProps) => {
const { channels, hasChannelChange, setDialogOpen } = useDevice();
const { setDialogOpen } = useDevice();
const editor = useConfigEditor();
const channels = useChannels();
const dirtyChannels = useSignal(editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL);
const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values());
const allChannels = useMemo(
() =>
channels.map((c) =>
create(Protobuf.Channel.ChannelSchema, {
index: c.index,
role: c.role,
settings: c.settings,
}),
),
[channels],
);
const flags = useMemo(
() => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])),
[allChannels, hasChannelChange],
() =>
new Map(allChannels.map((channel) => [channel.index, dirtyChannels.includes(channel.index)])),
[allChannels, dirtyChannels],
);
return (

View File

@@ -1,5 +1,5 @@
import { hasPos, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import type { Feature, FeatureCollection } from "geojson";
import type { HeatmapLayerSpecification } from "maplibre-gl";
import { useMemo } from "react";
@@ -11,9 +11,11 @@ export interface HeatmapLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
mode: HeatmapMode;
isVisible: boolean;
}
export const HeatmapLayer = ({ id, filteredNodes, mode }: HeatmapLayerProps) => {
export const HeatmapLayer = ({ id, filteredNodes, mode, isVisible }: HeatmapLayerProps) => {
if (!isVisible) return null;
const data: FeatureCollection = useMemo(() => {
const features: Feature[] = filteredNodes
.filter((node) => hasPos(node.position))

View File

@@ -13,9 +13,9 @@ import { NodeDetail } from "@components/PageComponents/Map/Popups/NodeDetail.tsx
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting";
import { useNodeDB } from "@core/stores";
import { hasPos, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import { useNodeErrors } from "@meshtastic/sdk-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { MapRef } from "react-map-gl/maplibre";
@@ -43,7 +43,9 @@ export const NodesLayer = ({
}: NodeMarkerProps): React.ReactNode[] => {
const { t } = useTranslation("map");
const { hasNodeError } = useNodeDB();
const errors = useNodeErrors();
const errorSet = useMemo(() => new Set(errors.map((e) => e.node)), [errors]);
const hasNodeError = useCallback((num: number) => errorSet.has(num), [errorSet]);
const { focusLngLat } = useMapFitting(mapRef);
const selectedNode = useMemo(

View File

@@ -1,6 +1,6 @@
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
import { precisionBitsToMeters, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import { circle } from "@turf/turf";
import type { Feature, FeatureCollection, Polygon } from "geojson";
import { Layer, Source } from "react-map-gl/maplibre";

View File

@@ -11,7 +11,7 @@ import {
mercatorToLngLat,
toLngLat,
} from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import type { Feature, FeatureCollection } from "geojson";
import { useTranslation } from "react-i18next";
import { Layer, Source } from "react-map-gl/maplibre";
@@ -31,7 +31,7 @@ export interface SNRTooltipProps {
pos: { x: number; y: number };
snr: number;
from: string;
to: string;
to: string | undefined;
}
type NeighborPlus = Protobuf.Mesh.Neighbor & {

View File

@@ -4,7 +4,7 @@ import { PopupWrapper } from "@components/PageComponents/Map/Popups/PopupWrapper
import { WaypointDetail } from "@components/PageComponents/Map/Popups/WaypointDetail.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting";
import { useDevice, type WaypointWithMetadata } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";

View File

@@ -6,8 +6,8 @@ import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@core/utils/string.ts";
import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/sdk";
import { Protobuf } from "@meshtastic/sdk";
import {
Tooltip,
TooltipContent,

View File

@@ -1,9 +1,9 @@
import { TimeAgo } from "@components/generic/TimeAgo";
import { Separator } from "@components/UI/Separator.tsx";
import { useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import type { WaypointWithMetadata } from "@core/stores";
import { useNodeDB } from "@core/stores";
import { bearingDegrees, distanceMeters, hasPos, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
import {
ClockFadingIcon,
ClockPlusIcon,
@@ -24,7 +24,7 @@ interface WaypointDetailProps {
export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
const { t } = useTranslation("map");
const { getNode } = useNodeDB();
const lockedToNode = useNodeAsProto(waypoint.lockedTo ?? 0);
const waypointLngLat = toLngLat({
latitudeI: waypoint.latitudeI,
@@ -166,7 +166,7 @@ export const WaypointDetail = ({ waypoint, myNode }: WaypointDetailProps) => {
<span className="truncate">{t("waypointDetail.lockedTo")}</span>
</dt>
<dd className="ms-auto text-right">
{getNode(waypoint.lockedTo)?.user?.longName ?? t("unknown.longName")}
{lockedToNode?.user?.longName ?? t("unknown.longName")}
</dd>
</div>
)}

View File

@@ -1,4 +1,4 @@
import type { Protobuf } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/sdk";
export type ClusterKey = string;
export type PxOffset = [number, number];

View File

@@ -1,7 +1,7 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import { Separator } from "@components/UI/Separator";
import { Skeleton } from "@components/UI/Skeleton.tsx";
import type { Message } from "@core/stores/messageStore/types.ts";
import type { Message } from "@core/stores/messageStore";
import type { TFunction } from "i18next";
import { InboxIcon } from "lucide-react";
import { Fragment, Suspense, useMemo } from "react";

View File

@@ -1,4 +1,4 @@
import type { Types } from "@meshtastic/core";
import type { ConversationKey } from "@meshtastic/sdk";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type MessageInputProps } from "./MessageInput.tsx";
@@ -24,28 +24,16 @@ vi.mock("@components/UI/Input.tsx", () => ({
)),
}));
const mockSetDraft = vi.fn();
const mockGetDraft = vi.fn();
const mockClearDraft = vi.fn();
const mockSetText = vi.fn();
const mockClear = vi.fn();
let mockDraftText = "";
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useMessages: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
vi.mock("@meshtastic/sdk-react", () => ({
useDraft: vi.fn(() => ({
text: mockDraftText,
setText: mockSetText,
clear: mockClear,
})),
MessageState: {
Ack: "ack",
Waiting: "waiting",
Failed: "failed",
},
MessageType: {
Direct: "direct",
Broadcast: "broadcast",
},
}));
vi.mock("lucide-react", () => ({
@@ -54,16 +42,17 @@ vi.mock("lucide-react", () => ({
describe("MessageInput", () => {
const mockOnSend = vi.fn();
const directConv: ConversationKey = { kind: "direct", peer: 123 };
const broadcastConv: ConversationKey = { kind: "channel", channel: 0 };
const defaultProps: MessageInputProps = {
onSend: mockOnSend,
to: 123,
conversation: directConv,
maxBytes: 256,
};
beforeEach(() => {
vi.clearAllMocks();
mockGetDraft.mockReturnValue("");
mockDraftText = "";
});
const renderComponent = (props: Partial<MessageInputProps> = {}) => {
@@ -78,22 +67,17 @@ describe("MessageInput", () => {
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
});
it("should initialize with the draft from the store", () => {
const initialDraft = "Existing draft message";
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : "";
});
it("should initialize with the persisted draft text from the SDK", () => {
mockDraftText = "Existing draft message";
renderComponent();
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
const expectedBytes = new Blob([mockDraftText]).size;
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
});
it("should update input value, byte counter, and call setDraft on change within limits", () => {
it("should update input value, byte counter, and call setText on change within limits", () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const testMessage = "Hello there!";
@@ -105,11 +89,11 @@ describe("MessageInput", () => {
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${expectedBytes}/${defaultProps.maxBytes}`,
);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
expect(mockSetText).toHaveBeenCalledTimes(1);
expect(mockSetText).toHaveBeenCalledWith(testMessage);
});
it("should NOT update input value or call setDraft if maxBytes is exceeded", () => {
it("should NOT update input value or call setText if maxBytes is exceeded", () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByTestId("message-input-field");
@@ -118,8 +102,8 @@ describe("MessageInput", () => {
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
expect(mockSetText).toHaveBeenCalledWith(initialValue);
mockSetText.mockClear();
fireEvent.change(inputElement, { target: { value: excessiveValue } });
@@ -127,10 +111,10 @@ describe("MessageInput", () => {
expect(screen.getByTestId("byte-counter")).toHaveTextContent(
`${smallMaxBytes}/${smallMaxBytes}`,
);
expect(mockSetDraft).not.toHaveBeenCalled();
expect(mockSetText).not.toHaveBeenCalled();
});
it("should call onSend, clear input, reset byte counter, and call clearDraft on valid submit", async () => {
it("should call onSend, clear input, reset byte counter, and call clear on valid submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
@@ -144,8 +128,7 @@ describe("MessageInput", () => {
expect(mockOnSend).toHaveBeenCalledWith(testMessage);
expect((inputElement as HTMLInputElement).value).toBe("");
expect(screen.getByTestId("byte-counter")).toHaveTextContent(`0/${defaultProps.maxBytes}`);
expect(mockClearDraft).toHaveBeenCalledTimes(1);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
expect(mockClear).toHaveBeenCalledTimes(1);
});
});
@@ -164,11 +147,11 @@ describe("MessageInput", () => {
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
expect(mockClear).toHaveBeenCalled();
});
});
it("should not call onSend or clearDraft if input is empty on submit", async () => {
it("should not call onSend or clear if input is empty on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
@@ -182,10 +165,10 @@ describe("MessageInput", () => {
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect(mockClear).not.toHaveBeenCalled();
});
it("should not call onSend or clearDraft if input contains only whitespace on submit", async () => {
it("should not call onSend or clear if input contains only whitespace on submit", async () => {
renderComponent();
const inputElement = screen.getByTestId("message-input-field");
const formElement = screen.getByRole("form");
@@ -201,18 +184,15 @@ describe("MessageInput", () => {
});
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect(mockClear).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it("should work with broadcast destination for drafts", () => {
const broadcastDest: Types.Destination = "broadcast";
mockGetDraft.mockImplementation((key) => (key === broadcastDest ? "Broadcast draft" : ""));
it("should work with a broadcast conversation key", () => {
mockDraftText = "Broadcast draft";
renderComponent({ conversation: broadcastConv });
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByTestId("message-input-field") as HTMLInputElement).value).toBe(
"Broadcast draft",
);
@@ -222,11 +202,11 @@ describe("MessageInput", () => {
const newMessage = "New broadcast msg";
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
expect(mockSetText).toHaveBeenCalledWith(newMessage);
fireEvent.submit(formElement);
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
expect(mockClear).toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,25 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useMessages } from "@core/stores";
import type { Types } from "@meshtastic/core";
import type { ConversationKey } from "@meshtastic/sdk";
import { useDraft } from "@meshtastic/sdk-react";
import { SendIcon } from "lucide-react";
import { startTransition, useState } from "react";
import { useTranslation } from "react-i18next";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
conversation: ConversationKey;
maxBytes: number;
}
export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessages();
export const MessageInput = ({ onSend, conversation, maxBytes }: MessageInputProps) => {
const { text: persistedDraft, setText, clear } = useDraft(conversation);
const { t } = useTranslation("messages");
const calculateBytes = (text: string) => new Blob([text]).size;
const calculateBytes = (value: string) => new Blob([value]).size;
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
const [localDraft, setLocalDraft] = useState(persistedDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(persistedDraft));
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
@@ -29,7 +28,7 @@ export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
setMessageBytes(byteLength);
setDraft(to, newValue);
setText(newValue);
}
};
@@ -38,13 +37,12 @@ export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
if (!localDraft.trim()) {
return;
}
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0);
startTransition(() => {
onSend(localDraft.trim());
setLocalDraft("");
clearDraft(to);
clear();
});
};

View File

@@ -6,10 +6,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/UI/Tooltip.tsx";
import { MessageState, useAppStore, useDevice, useNodeDB } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { useMyNodeAsProto, useNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useAppStore, useDevice } from "@core/stores";
import { type Message, MessageState } from "@core/stores/messageStore";
import { cn } from "@core/utils/cn.ts";
import { type Protobuf, Types } from "@meshtastic/core";
import { type Protobuf, Types } from "@meshtastic/sdk";
import type { LucideIcon } from "lucide-react";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import { type ReactNode, useMemo } from "react";
@@ -18,39 +19,41 @@ import { useTranslation } from "react-i18next";
// Cache for pending promises
const myNodePromises = new Map<string, Promise<Protobuf.Mesh.NodeInfo>>();
// Hook that suspends when myNode is not available
// Hook that suspends when myNode is not available. Reads from the SDK
// (NodesClient signal hydrated by sqlocal + live packets) instead of the
// legacy Zustand nodeDB. The polling fallback remains because there is a
// gap between mount and first onMyNodeInfo packet on a fresh connect.
function useSuspendingMyNode() {
const { getMyNode } = useNodeDB();
const selectedDeviceId = useAppStore((s) => s.selectedDeviceId);
const myNode = getMyNode();
const myNode = useMyNodeAsProto();
if (!myNode) {
// Use the selected device ID to cache promises per device
const deviceKey = `device-${selectedDeviceId}`;
if (!myNodePromises.has(deviceKey)) {
const promise = new Promise<Protobuf.Mesh.NodeInfo>((resolve) => {
// Poll for myNode to become available
const checkInterval = setInterval(() => {
const node = getMyNode();
if (node) {
console.log("[MessageItem] myNode now available, resolving promise");
clearInterval(checkInterval);
const promise = new Promise<Protobuf.Mesh.NodeInfo>((resolve, reject) => {
// setTimeout with a 100ms tick lets React re-render this component
// (and therefore re-run the hook) until myNode resolves through the
// SDK signal. Suspense re-throws the promise on each retry until
// the value is available.
const start = Date.now();
const tick = () => {
if (Date.now() - start > 10000) {
myNodePromises.delete(deviceKey);
resolve(node);
reject(new Error("myNode not available after 10s"));
return;
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
// Resolve a no-op promise to retrigger the Suspense boundary;
// the next render will call useMyNodeAsProto again.
resolve({} as Protobuf.Mesh.NodeInfo);
myNodePromises.delete(deviceKey);
}, 10000);
};
setTimeout(tick, 100);
});
myNodePromises.set(deviceKey, promise);
}
// Throw the promise to trigger Suspense
throw myNodePromises.get(deviceKey);
}
@@ -90,8 +93,8 @@ interface MessageItemProps {
export const MessageItem = ({ message }: MessageItemProps) => {
const { config } = useDevice();
const { getNode } = useNodeDB();
const { t, i18n } = useTranslation("messages");
const messageUserNode = useNodeAsProto(message.from ?? 0);
// This will suspend if myNode is not available yet
const myNode = useSuspendingMyNode();
@@ -138,9 +141,8 @@ export const MessageItem = ({ message }: MessageItemProps) => {
[MESSAGE_STATUS_MAP, UNKNOWN_STATUS],
);
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
const messageUser: Protobuf.Mesh.NodeInfo | null | undefined =
message.from != null ? (messageUserNode ?? null) : null;
const { displayName, isFavorite, nodeNum } = useMemo(() => {
const userIdHex = message.from.toString(16).toUpperCase().padStart(2, "0");

View File

@@ -1,11 +1,10 @@
import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx";
import { useNodeDB } from "@core/stores";
import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts";
import { Protobuf } from "@meshtastic/core";
import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
import { Protobuf } from "@meshtastic/sdk";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@core/stores");
vi.mock("@core/hooks/useNodesAsProto.ts");
describe("TraceRoute", () => {
const fromUser = {
@@ -65,12 +64,7 @@ describe("TraceRoute", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(useNodeDB).mockReturnValue({
...mockNodeDBStore,
getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => {
return mockNodes.get(nodeNum);
},
});
vi.mocked(useNodesAsProto).mockReturnValue(Array.from(mockNodes.values()));
});
it("renders the route to destination with SNR values", () => {

View File

@@ -1,5 +1,5 @@
import { useNodeDB } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
import type { Protobuf } from "@meshtastic/sdk";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useTranslation } from "react-i18next";
@@ -23,7 +23,8 @@ interface RoutePathProps {
}
const RoutePath = ({ title, from, to, path, snr }: RoutePathProps) => {
const { getNode } = useNodeDB();
const allNodes = useNodesAsProto();
const getNode = (n: number) => allNodes.find((node) => node.num === n);
const { t } = useTranslation();
return (

View File

@@ -5,28 +5,40 @@ import {
} from "@app/validation/moduleConfig/ambientLighting.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { ambientLighting?: Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig },
peek: () =>
({}) as { ambientLighting?: Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig },
subscribe: () => () => {},
} as const;
export const AmbientLighting = ({ onFormInit }: AmbientLightingModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "ambientLighting" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.ambientLighting ??
(getEffectiveModuleConfig("ambientLighting") as
| Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
removeChange({ type: "moduleConfig", variant: "ambientLighting" });
return;
}
setChange(
{ type: "moduleConfig", variant: "ambientLighting" },
data,
moduleConfig.ambientLighting,
if (!editor) return;
editor.setModuleSection(
"ambientLighting",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig,
);
};
@@ -36,11 +48,11 @@ export const AmbientLighting = ({ onFormInit }: AmbientLightingModuleConfigProps
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
values={effective}
fieldGroups={[
{
label: t("ambientLighting.title"),
description: t("ambientLighting.description"),
label: t("ambientLighting.ambientLightingConfig.label"),
description: t("ambientLighting.ambientLightingConfig.description"),
fields: [
{
type: "toggle",

View File

@@ -2,26 +2,40 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type AudioValidation, AudioValidationSchema } from "@app/validation/moduleConfig/audio.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { audio?: Protobuf.ModuleConfig.ModuleConfig_AudioConfig },
peek: () => ({}) as { audio?: Protobuf.ModuleConfig.ModuleConfig_AudioConfig },
subscribe: () => () => {},
} as const;
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "audio" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.audio ??
(getEffectiveModuleConfig("audio") as
| Protobuf.ModuleConfig.ModuleConfig_AudioConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) {
removeChange({ type: "moduleConfig", variant: "audio" });
return;
}
setChange({ type: "moduleConfig", variant: "audio" }, data, moduleConfig.audio);
if (!editor) return;
editor.setModuleSection(
"audio",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_AudioConfig,
);
};
return (
@@ -30,11 +44,11 @@ export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={AudioValidationSchema}
defaultValues={moduleConfig.audio}
values={getEffectiveModuleConfig("audio")}
values={effective}
fieldGroups={[
{
label: t("audio.title"),
description: t("audio.description"),
label: t("audio.audioConfig.label"),
description: t("audio.audioConfig.description"),
fields: [
{
type: "toggle",

View File

@@ -5,27 +5,40 @@ import {
} from "@app/validation/moduleConfig/cannedMessage.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { cannedMessage?: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig },
peek: () => ({}) as { cannedMessage?: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig },
subscribe: () => () => {},
} as const;
export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "cannedMessage" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.cannedMessage ??
(getEffectiveModuleConfig("cannedMessage") as
| Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
removeChange({ type: "moduleConfig", variant: "cannedMessage" });
return;
}
setChange({ type: "moduleConfig", variant: "cannedMessage" }, data, moduleConfig.cannedMessage);
if (!editor) return;
editor.setModuleSection(
"cannedMessage",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig,
);
};
return (
@@ -34,17 +47,17 @@ export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) =>
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
values={effective}
fieldGroups={[
{
label: t("cannedMessage.title"),
description: t("cannedMessage.description"),
label: t("cannedMessage.cannedMessageConfig.label"),
description: t("cannedMessage.cannedMessageConfig.description"),
fields: [
{
type: "toggle",
name: "enabled",
label: t("cannedMessage.moduleEnabled.label"),
description: t("cannedMessage.moduleEnabled.description"),
label: t("cannedMessage.enabled.label"),
description: t("cannedMessage.enabled.description"),
},
{
type: "toggle",
@@ -70,6 +83,15 @@ export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) =>
label: t("cannedMessage.inputbrokerPinPress.label"),
description: t("cannedMessage.inputbrokerPinPress.description"),
},
{
type: "select",
name: "inputbrokerEventPress",
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventCw",
@@ -88,15 +110,6 @@ export const CannedMessage = ({ onFormInit }: CannedMessageModuleConfigProps) =>
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "select",
name: "inputbrokerEventPress",
label: t("cannedMessage.inputbrokerEventPress.label"),
description: t("cannedMessage.inputbrokerEventPress.description"),
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
},
},
{
type: "toggle",
name: "updown1Enabled",

View File

@@ -5,30 +5,40 @@ import {
} from "@app/validation/moduleConfig/detectionSensor.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { detectionSensor?: Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig },
peek: () =>
({}) as { detectionSensor?: Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig },
subscribe: () => () => {},
} as const;
export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "detectionSensor" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.detectionSensor ??
(getEffectiveModuleConfig("detectionSensor") as
| Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
removeChange({ type: "moduleConfig", variant: "detectionSensor" });
return;
}
setChange(
{ type: "moduleConfig", variant: "detectionSensor" },
data,
moduleConfig.detectionSensor,
if (!editor) return;
editor.setModuleSection(
"detectionSensor",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig,
);
};
@@ -38,11 +48,11 @@ export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
values={effective}
fieldGroups={[
{
label: t("detectionSensor.title"),
description: t("detectionSensor.description"),
label: t("detectionSensor.detectionSensorConfig.label"),
description: t("detectionSensor.detectionSensorConfig.description"),
fields: [
{
type: "toggle",
@@ -55,71 +65,41 @@ export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps
name: "minimumBroadcastSecs",
label: t("detectionSensor.minimumBroadcastSecs.label"),
description: t("detectionSensor.minimumBroadcastSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "stateBroadcastSecs",
label: t("detectionSensor.stateBroadcastSecs.label"),
description: t("detectionSensor.stateBroadcastSecs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "sendBell",
label: t("detectionSensor.sendBell.label"),
description: t("detectionSensor.sendBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "text",
name: "name",
label: t("detectionSensor.name.label"),
description: t("detectionSensor.name.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "monitorPin",
label: t("detectionSensor.monitorPin.label"),
description: t("detectionSensor.monitorPin.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "detectionTriggerType",
label: t("detectionSensor.detectionTriggerType.label"),
description: t("detectionSensor.detectionTriggerType.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
formatEnumName: true,
},
},
{
@@ -127,11 +107,6 @@ export const DetectionSensor = ({ onFormInit }: DetectionSensorModuleConfigProps
name: "usePullup",
label: t("detectionSensor.usePullup.label"),
description: t("detectionSensor.usePullup.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},

View File

@@ -5,29 +5,44 @@ import {
} from "@app/validation/moduleConfig/externalNotification.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as {
externalNotification?: Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig;
},
peek: () =>
({}) as {
externalNotification?: Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig;
},
subscribe: () => () => {},
} as const;
export const ExternalNotification = ({ onFormInit }: ExternalNotificationModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "externalNotification" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.externalNotification ??
(getEffectiveModuleConfig("externalNotification") as
| Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
removeChange({ type: "moduleConfig", variant: "externalNotification" });
return;
}
setChange(
{ type: "moduleConfig", variant: "externalNotification" },
data,
moduleConfig.externalNotification,
if (!editor) return;
editor.setModuleSection(
"externalNotification",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig,
);
};
@@ -37,11 +52,11 @@ export const ExternalNotification = ({ onFormInit }: ExternalNotificationModuleC
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
values={effective}
fieldGroups={[
{
label: t("externalNotification.title"),
description: t("externalNotification.description"),
label: t("externalNotification.externalNotificationConfig.label"),
description: t("externalNotification.externalNotificationConfig.description"),
fields: [
{
type: "toggle",
@@ -49,163 +64,109 @@ export const ExternalNotification = ({ onFormInit }: ExternalNotificationModuleC
label: t("externalNotification.enabled.label"),
description: t("externalNotification.enabled.description"),
},
{
type: "number",
name: "outputMs",
label: t("externalNotification.outputMs.label"),
description: t("externalNotification.outputMs.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.millisecond.suffix"),
},
},
{
type: "number",
name: "output",
label: t("externalNotification.output.label"),
description: t("externalNotification.output.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputVibra",
label: t("externalNotification.outputVibra.label"),
description: t("externalNotification.outputVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputBuzzer",
label: t("externalNotification.outputBuzzer.label"),
description: t("externalNotification.outputBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "active",
label: t("externalNotification.active.label"),
description: t("externalNotification.active.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},
{
label: t("externalNotification.notificationsOnMessage.label"),
description: t("externalNotification.notificationsOnMessage.description"),
fields: [
{
type: "toggle",
name: "alertMessage",
label: t("externalNotification.alertMessage.label"),
description: t("externalNotification.alertMessage.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageVibra",
label: t("externalNotification.alertMessageVibra.label"),
description: t("externalNotification.alertMessageVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageBuzzer",
label: t("externalNotification.alertMessageBuzzer.label"),
description: t("externalNotification.alertMessageBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertMessageVibra",
label: t("externalNotification.alertMessageVibra.label"),
description: t("externalNotification.alertMessageVibra.description"),
},
],
},
{
label: t("externalNotification.notificationsOnAlert.label"),
description: t("externalNotification.notificationsOnAlert.description"),
fields: [
{
type: "toggle",
name: "alertBell",
label: t("externalNotification.alertBell.label"),
description: t("externalNotification.alertBell.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellVibra",
label: t("externalNotification.alertBellVibra.label"),
description: t("externalNotification.alertBellVibra.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellBuzzer",
label: t("externalNotification.alertBellBuzzer.label"),
description: t("externalNotification.alertBellBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "alertBellVibra",
label: t("externalNotification.alertBellVibra.label"),
description: t("externalNotification.alertBellVibra.description"),
},
],
},
{
label: t("externalNotification.advanced.label"),
description: t("externalNotification.advanced.description"),
fields: [
{
type: "number",
name: "output",
label: t("externalNotification.output.label"),
description: t("externalNotification.output.description"),
},
{
type: "toggle",
name: "active",
label: t("externalNotification.active.label"),
description: t("externalNotification.active.description"),
},
{
type: "number",
name: "outputBuzzer",
label: t("externalNotification.outputBuzzer.label"),
description: t("externalNotification.outputBuzzer.description"),
},
{
type: "toggle",
name: "usePwm",
label: t("externalNotification.usePwm.label"),
description: t("externalNotification.usePwm.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "outputVibra",
label: t("externalNotification.outputVibra.label"),
description: t("externalNotification.outputVibra.description"),
},
{
type: "number",
name: "outputMs",
label: t("externalNotification.outputMs.label"),
description: t("externalNotification.outputMs.description"),
properties: { suffix: t("unit.millisecond.suffix") },
},
{
type: "number",
name: "nagTimeout",
label: t("externalNotification.nagTimeout.label"),
description: t("externalNotification.nagTimeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "useI2sAsBuzzer",
label: t("externalNotification.useI2sAsBuzzer.label"),
description: t("externalNotification.useI2sAsBuzzer.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},

View File

@@ -3,21 +3,47 @@ import { type MqttValidation, MqttValidationSchema } from "@app/validation/modul
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface MqttModuleConfigProps {
onFormInit: DynamicFormFormInit<MqttValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { mqtt?: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig },
peek: () => ({}) as { mqtt?: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig },
subscribe: () => () => {},
} as const;
const populateDefaults = (cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined) =>
cfg
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ?? {
publishIntervalSecs: 3600,
positionPrecision: 13,
shouldReportLocation: false,
},
}
: undefined;
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "mqtt" });
const { config, moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { config, moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effectiveMqtt =
modules.mqtt ??
(getEffectiveModuleConfig("mqtt") as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => {
if (!editor) return;
const payload = {
...data,
mapReportSettings: create(
@@ -25,40 +51,42 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
data.mapReportSettings,
),
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeChange({ type: "moduleConfig", variant: "mqtt" });
return;
}
setChange({ type: "moduleConfig", variant: "mqtt" }, payload, moduleConfig.mqtt);
editor.setModuleSection(
"mqtt",
payload as unknown as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig,
);
};
const populateDefaultValues = (
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
) => {
return cfg
const positionPrecisionOptions =
config.display?.units === 0
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ?? {
publishIntervalSecs: 0,
positionPrecision: 10,
},
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")]: 15,
}
: undefined;
};
: {
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")]: 15,
};
return (
<DynamicForm<MqttValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={MqttValidationSchema}
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
defaultValues={populateDefaults(moduleConfig.mqtt)}
values={populateDefaults(effectiveMqtt)}
fieldGroups={[
{
label: t("mqtt.title"),
description: t("mqtt.description"),
label: t("mqtt.mqttConfigCard.label"),
description: t("mqtt.mqttConfigCard.description"),
fields: [
{
type: "toggle",
@@ -71,98 +99,87 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
name: "address",
label: t("mqtt.address.label"),
description: t("mqtt.address.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "text",
name: "username",
label: t("mqtt.username.label"),
description: t("mqtt.username.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "password",
name: "password",
label: t("mqtt.password.label"),
description: t("mqtt.password.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "toggle",
name: "encryptionEnabled",
label: t("mqtt.encryptionEnabled.label"),
description: t("mqtt.encryptionEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "toggle",
name: "jsonEnabled",
label: t("mqtt.jsonEnabled.label"),
description: t("mqtt.jsonEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "toggle",
name: "tlsEnabled",
label: t("mqtt.tlsEnabled.label"),
description: t("mqtt.tlsEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "text",
name: "root",
label: t("mqtt.root.label"),
description: t("mqtt.root.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "toggle",
name: "proxyToClientEnabled",
label: t("mqtt.proxyToClientEnabled.label"),
description: t("mqtt.proxyToClientEnabled.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
disabledBy: [{ fieldName: "enabled" }],
},
],
},
{
label: t("mqtt.mapReportingCard.label"),
description: t("mqtt.mapReportingCard.description"),
fields: [
{
type: "toggle",
name: "mapReportingEnabled",
label: t("mqtt.mapReportingEnabled.label"),
description: t("mqtt.mapReportingEnabled.description"),
disabledBy: [{ fieldName: "enabled" }],
},
{
type: "toggle",
name: "mapReportSettings.shouldReportLocation",
label: t("mqtt.mapReportSettings.shouldReportLocation.label"),
description: t("mqtt.mapReportSettings.shouldReportLocation.description"),
disabledBy: [{ fieldName: "enabled" }, { fieldName: "mapReportingEnabled" }],
},
{
type: "select",
name: "mapReportSettings.positionPrecision",
label: t("mqtt.mapReportSettings.positionPrecision.label"),
description: t("mqtt.mapReportSettings.positionPrecision.description"),
properties: { enumValue: positionPrecisionOptions },
disabledBy: [
{
fieldName: "enabled",
},
{ fieldName: "enabled" },
{ fieldName: "mapReportingEnabled" },
{ fieldName: "mapReportSettings.shouldReportLocation" },
],
},
{
@@ -170,58 +187,11 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
name: "mapReportSettings.publishIntervalSecs",
label: t("mqtt.mapReportSettings.publishIntervalSecs.label"),
description: t("mqtt.mapReportSettings.publishIntervalSecs.description"),
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
],
},
{
type: "select",
name: "mapReportSettings.positionPrecision",
label: t("mqtt.mapReportSettings.positionPrecision.label"),
description: t("mqtt.mapReportSettings.positionPrecision.description"),
properties: {
enumValue:
config.display?.units === 0
? {
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km23")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km12")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km5_8")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km2_9")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_km1_5")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m700")]: 15,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m350")]: 16,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m200")]: 17,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m90")]: 18,
[t("mqtt.mapReportSettings.positionPrecision.options.metric_m50")]: 19,
}
: {
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi15")]: 10,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi7_3")]: 11,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi3_6")]: 12,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi1_8")]: 13,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_9")]: 14,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_5")]: 15,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_mi0_2")]: 16,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft600")]: 17,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft300")]: 18,
[t("mqtt.mapReportSettings.positionPrecision.options.imperial_ft150")]: 19,
},
},
disabledBy: [
{
fieldName: "enabled",
},
{
fieldName: "mapReportingEnabled",
},
{ fieldName: "enabled" },
{ fieldName: "mapReportingEnabled" },
{ fieldName: "mapReportSettings.shouldReportLocation" },
],
},
],

View File

@@ -5,26 +5,40 @@ import {
} from "@app/validation/moduleConfig/neighborInfo.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface NeighborInfoModuleConfigProps {
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { neighborInfo?: Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig },
peek: () => ({}) as { neighborInfo?: Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig },
subscribe: () => () => {},
} as const;
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "neighborInfo" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.neighborInfo ??
(getEffectiveModuleConfig("neighborInfo") as
| Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
removeChange({ type: "moduleConfig", variant: "neighborInfo" });
return;
}
setChange({ type: "moduleConfig", variant: "neighborInfo" }, data, moduleConfig.neighborInfo);
if (!editor) return;
editor.setModuleSection(
"neighborInfo",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig,
);
};
return (
@@ -33,11 +47,11 @@ export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={NeighborInfoValidationSchema}
defaultValues={moduleConfig.neighborInfo}
values={getEffectiveModuleConfig("neighborInfo")}
values={effective}
fieldGroups={[
{
label: t("neighborInfo.title"),
description: t("neighborInfo.description"),
label: t("neighborInfo.neighborInfoConfig.label"),
description: t("neighborInfo.neighborInfoConfig.description"),
fields: [
{
type: "toggle",
@@ -50,14 +64,13 @@ export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
name: "updateInterval",
label: t("neighborInfo.updateInterval.label"),
description: t("neighborInfo.updateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "transmitOverLora",
label: t("neighborInfo.transmitOverLora.label"),
description: t("neighborInfo.transmitOverLora.description"),
},
],
},

View File

@@ -5,26 +5,40 @@ import {
} from "@app/validation/moduleConfig/paxcounter.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface PaxcounterModuleConfigProps {
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { paxcounter?: Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig },
peek: () => ({}) as { paxcounter?: Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig },
subscribe: () => () => {},
} as const;
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "paxcounter" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.paxcounter ??
(getEffectiveModuleConfig("paxcounter") as
| Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
removeChange({ type: "moduleConfig", variant: "paxcounter" });
return;
}
setChange({ type: "moduleConfig", variant: "paxcounter" }, data, moduleConfig.paxcounter);
if (!editor) return;
editor.setModuleSection(
"paxcounter",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig,
);
};
return (
@@ -33,11 +47,11 @@ export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={PaxcounterValidationSchema}
defaultValues={moduleConfig.paxcounter}
values={getEffectiveModuleConfig("paxcounter")}
values={effective}
fieldGroups={[
{
label: t("paxcounter.title"),
description: t("paxcounter.description"),
label: t("paxcounter.paxcounterConfig.label"),
description: t("paxcounter.paxcounterConfig.description"),
fields: [
{
type: "toggle",
@@ -50,36 +64,19 @@ export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
name: "paxcounterUpdateInterval",
label: t("paxcounter.paxcounterUpdateInterval.label"),
description: t("paxcounter.paxcounterUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "wifiThreshold",
label: t("paxcounter.wifiThreshold.label"),
description: t("paxcounter.wifiThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "bleThreshold",
label: t("paxcounter.bleThreshold.label"),
description: t("paxcounter.bleThreshold.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},

View File

@@ -5,27 +5,40 @@ import {
} from "@app/validation/moduleConfig/rangeTest.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface RangeTestModuleConfigProps {
onFormInit: DynamicFormFormInit<RangeTestValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { rangeTest?: Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig },
peek: () => ({}) as { rangeTest?: Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig },
subscribe: () => () => {},
} as const;
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "rangeTest" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.rangeTest ??
(getEffectiveModuleConfig("rangeTest") as
| Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
removeChange({ type: "moduleConfig", variant: "rangeTest" });
return;
}
setChange({ type: "moduleConfig", variant: "rangeTest" }, data, moduleConfig.rangeTest);
if (!editor) return;
editor.setModuleSection(
"rangeTest",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig,
);
};
return (
@@ -34,11 +47,11 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={RangeTestValidationSchema}
defaultValues={moduleConfig.rangeTest}
values={getEffectiveModuleConfig("rangeTest")}
values={effective}
fieldGroups={[
{
label: t("rangeTest.title"),
description: t("rangeTest.description"),
label: t("rangeTest.rangeTestConfig.label"),
description: t("rangeTest.rangeTestConfig.description"),
fields: [
{
type: "toggle",
@@ -51,25 +64,13 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
name: "sender",
label: t("rangeTest.sender.label"),
description: t("rangeTest.sender.description"),
properties: {
suffix: t("unit.second.plural"),
},
disabledBy: [
{
fieldName: "enabled",
},
],
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "save",
label: t("rangeTest.save.label"),
description: t("rangeTest.save.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
],
},

View File

@@ -5,27 +5,40 @@ import {
} from "@app/validation/moduleConfig/serial.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface SerialModuleConfigProps {
onFormInit: DynamicFormFormInit<SerialValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { serial?: Protobuf.ModuleConfig.ModuleConfig_SerialConfig },
peek: () => ({}) as { serial?: Protobuf.ModuleConfig.ModuleConfig_SerialConfig },
subscribe: () => () => {},
} as const;
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "serial" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.serial ??
(getEffectiveModuleConfig("serial") as
| Protobuf.ModuleConfig.ModuleConfig_SerialConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) {
removeChange({ type: "moduleConfig", variant: "serial" });
return;
}
setChange({ type: "moduleConfig", variant: "serial" }, data, moduleConfig.serial);
if (!editor) return;
editor.setModuleSection(
"serial",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_SerialConfig,
);
};
return (
@@ -34,11 +47,11 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={SerialValidationSchema}
defaultValues={moduleConfig.serial}
values={getEffectiveModuleConfig("serial")}
values={effective}
fieldGroups={[
{
label: t("serial.title"),
description: t("serial.description"),
label: t("serial.serialConfig.label"),
description: t("serial.serialConfig.description"),
fields: [
{
type: "toggle",
@@ -51,45 +64,24 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
name: "echo",
label: t("serial.echo.label"),
description: t("serial.echo.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "rxd",
label: t("serial.rxd.label"),
description: t("serial.rxd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "txd",
label: t("serial.txd.label"),
description: t("serial.txd.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "select",
name: "baud",
label: t("serial.baud.label"),
description: t("serial.baud.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
},
@@ -99,26 +91,13 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
name: "timeout",
label: t("serial.timeout.label"),
description: t("serial.timeout.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
},
{
type: "select",
name: "mode",
label: t("serial.mode.label"),
description: t("serial.mode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
formatEnumName: true,

View File

@@ -5,26 +5,40 @@ import {
} from "@app/validation/moduleConfig/storeForward.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface StoreForwardModuleConfigProps {
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { storeForward?: Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig },
peek: () => ({}) as { storeForward?: Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig },
subscribe: () => () => {},
} as const;
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "storeForward" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.storeForward ??
(getEffectiveModuleConfig("storeForward") as
| Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig
| undefined);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
removeChange({ type: "moduleConfig", variant: "storeForward" });
return;
}
setChange({ type: "moduleConfig", variant: "storeForward" }, data, moduleConfig.storeForward);
if (!editor) return;
editor.setModuleSection(
"storeForward",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
);
};
return (
@@ -33,11 +47,11 @@ export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={StoreForwardValidationSchema}
defaultValues={moduleConfig.storeForward}
values={getEffectiveModuleConfig("storeForward")}
values={effective}
fieldGroups={[
{
label: t("storeForward.title"),
description: t("storeForward.description"),
label: t("storeForward.storeForwardConfig.label"),
description: t("storeForward.storeForwardConfig.description"),
fields: [
{
type: "toggle",
@@ -50,47 +64,31 @@ export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
name: "heartbeat",
label: t("storeForward.heartbeat.label"),
description: t("storeForward.heartbeat.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "records",
label: t("storeForward.records.label"),
description: t("storeForward.records.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
suffix: t("unit.record.plural"),
},
properties: { suffix: t("unit.record.plural") },
},
{
type: "number",
name: "historyReturnMax",
label: t("storeForward.historyReturnMax.label"),
description: t("storeForward.historyReturnMax.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "number",
name: "historyReturnWindow",
label: t("storeForward.historyReturnWindow.label"),
description: t("storeForward.historyReturnWindow.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
},
{
type: "toggle",
name: "isServer",
label: t("storeForward.isServer.label"),
description: t("storeForward.isServer.description"),
},
],
},

View File

@@ -5,26 +5,67 @@ import {
} from "@app/validation/moduleConfig/telemetry.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface TelemetryModuleConfigProps {
onFormInit: DynamicFormFormInit<TelemetryValidation>;
}
const EMPTY_MODULES_SIGNAL = {
value: {} as { telemetry?: Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig },
peek: () => ({}) as { telemetry?: Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig },
subscribe: () => () => {},
} as const;
/**
* Compare a Meshtastic firmware version string ("X.Y.Z" or "X.Y.Z.suffix")
* against a (major, minor, patch) tuple. Returns true if the running
* firmware is at least that version. Unknown / unparseable versions return
* `true` to avoid hiding the toggle from devices we can't classify.
*/
function firmwareAtLeast(
version: string | undefined,
major: number,
minor: number,
patch: number,
): boolean {
if (!version) return true;
const parts = version.split(/[.\-+]/).map((s) => Number.parseInt(s, 10));
const [maj = 0, min = 0, pat = 0] = parts;
if (Number.isNaN(maj) || Number.isNaN(min) || Number.isNaN(pat)) return true;
if (maj !== major) return maj > major;
if (min !== minor) return min > minor;
return pat >= patch;
}
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
useWaitForConfig({ moduleConfigCase: "telemetry" });
const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = useDevice();
const { moduleConfig, metadata, getEffectiveModuleConfig } = useDevice();
const editor = useConfigEditor();
const modules = useSignal(editor?.modules ?? EMPTY_MODULES_SIGNAL);
const effective =
modules.telemetry ??
(getEffectiveModuleConfig("telemetry") as
| Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig
| undefined);
// Mirrors the Android `Capabilities.canToggleTelemetryEnabled` gate:
// device_telemetry_enabled is only writable on firmware ≥ v2.7.12. Hide
// the toggle on older firmware so we don't push a value the device will
// ignore.
const canToggleTelemetry = firmwareAtLeast(metadata.get(0)?.firmwareVersion, 2, 7, 12);
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
removeChange({ type: "moduleConfig", variant: "telemetry" });
return;
}
setChange({ type: "moduleConfig", variant: "telemetry" }, data, moduleConfig.telemetry);
if (!editor) return;
editor.setModuleSection(
"telemetry",
data as unknown as Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig,
);
};
return (
@@ -33,29 +74,28 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
onFormInit={onFormInit}
validationSchema={TelemetryValidationSchema}
defaultValues={moduleConfig.telemetry}
values={getEffectiveModuleConfig("telemetry")}
values={effective}
fieldGroups={[
{
label: t("telemetry.title"),
description: t("telemetry.description"),
label: t("telemetry.telemetryConfig.label"),
description: t("telemetry.telemetryConfig.description"),
fields: [
...(canToggleTelemetry
? [
{
type: "toggle" as const,
name: "deviceTelemetryEnabled" as const,
label: t("telemetry.deviceTelemetryEnabled.label"),
description: t("telemetry.deviceTelemetryEnabled.description"),
},
]
: []),
{
type: "number",
name: "deviceUpdateInterval",
label: t("telemetry.deviceUpdateInterval.label"),
description: t("telemetry.deviceUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "environmentUpdateInterval",
label: t("telemetry.environmentUpdateInterval.label"),
description: t("telemetry.environmentUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
@@ -63,6 +103,13 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
label: t("telemetry.environmentMeasurementEnabled.label"),
description: t("telemetry.environmentMeasurementEnabled.description"),
},
{
type: "number",
name: "environmentUpdateInterval",
label: t("telemetry.environmentUpdateInterval.label"),
description: t("telemetry.environmentUpdateInterval.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "environmentScreenEnabled",
@@ -86,6 +133,7 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
name: "airQualityInterval",
label: t("telemetry.airQualityInterval.label"),
description: t("telemetry.airQualityInterval.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
@@ -104,6 +152,7 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
name: "powerUpdateInterval",
label: t("telemetry.powerUpdateInterval.label"),
description: t("telemetry.powerUpdateInterval.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",

View File

@@ -5,26 +5,35 @@ import {
} from "@app/validation/config/bluetooth.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { bluetooth?: Protobuf.Config.Config_BluetoothConfig },
peek: () => ({}) as { bluetooth?: Protobuf.Config.Config_BluetoothConfig },
subscribe: () => () => {},
} as const;
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
useWaitForConfig({ configCase: "bluetooth" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.bluetooth ??
(getEffectiveConfig("bluetooth") as Protobuf.Config.Config_BluetoothConfig | undefined);
const { t } = useTranslation("config");
const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) {
removeChange({ type: "config", variant: "bluetooth" });
return;
}
setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth);
if (!editor) return;
editor.setRadioSection("bluetooth", data as unknown as Protobuf.Config.Config_BluetoothConfig);
};
return (
@@ -33,11 +42,11 @@ export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema}
defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
values={effective}
fieldGroups={[
{
label: t("bluetooth.title"),
description: t("bluetooth.description"),
label: t("bluetooth.bluetoothConfig.label"),
description: t("bluetooth.bluetoothConfig.description"),
notes: t("bluetooth.note"),
fields: [
{
@@ -51,11 +60,6 @@ export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
name: "mode",
label: t("bluetooth.pairingMode.label"),
description: t("bluetooth.pairingMode.description"),
disabledBy: [
{
fieldName: "enabled",
},
],
properties: {
enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode,
formatEnumName: true,

View File

@@ -3,27 +3,36 @@ import { type DeviceValidation, DeviceValidationSchema } from "@app/validation/c
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { device?: Protobuf.Config.Config_DeviceConfig },
peek: () => ({}) as { device?: Protobuf.Config.Config_DeviceConfig },
subscribe: () => () => {},
} as const;
export const Device = ({ onFormInit }: DeviceConfigProps) => {
useWaitForConfig({ configCase: "device" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.device ??
(getEffectiveConfig("device") as Protobuf.Config.Config_DeviceConfig | undefined);
const { t } = useTranslation("config");
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) {
removeChange({ type: "config", variant: "device" });
return;
}
setChange({ type: "config", variant: "device" }, data, config.device);
if (!editor) return;
editor.setRadioSection("device", data as unknown as Protobuf.Config.Config_DeviceConfig);
};
return (
@@ -32,11 +41,11 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema}
defaultValues={config.device}
values={getEffectiveConfig("device")}
values={effective}
fieldGroups={[
{
label: t("device.title"),
description: t("device.description"),
label: t("device.options.label"),
description: t("device.options.description"),
fields: [
{
type: "select",
@@ -49,18 +58,6 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
formatEnumName: true,
},
},
{
type: "number",
name: "buttonGpio",
label: t("device.buttonPin.label"),
description: t("device.buttonPin.description"),
},
{
type: "number",
name: "buzzerGpio",
label: t("device.buzzerPin.label"),
description: t("device.buzzerPin.description"),
},
{
type: "select",
name: "rebroadcastMode",
@@ -76,10 +73,14 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
name: "nodeInfoBroadcastSecs",
label: t("device.nodeInfoBroadcastInterval.label"),
description: t("device.nodeInfoBroadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
},
],
},
{
label: t("device.hardware.label"),
description: t("device.hardware.description"),
fields: [
{
type: "toggle",
name: "doubleTapAsButtonPress",
@@ -92,19 +93,6 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
label: t("device.disableTripleClick.label"),
description: t("device.disableTripleClick.description"),
},
{
type: "text",
name: "tzdef",
label: t("device.posixTimezone.label"),
description: t("device.posixTimezone.description"),
properties: {
fieldLength: {
max: 64,
currentValueLength: getEffectiveConfig("device")?.tzdef?.length,
showCharacterCount: true,
},
},
},
{
type: "toggle",
name: "ledHeartbeatDisabled",
@@ -113,6 +101,43 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
},
],
},
{
label: t("device.timeZone.label"),
description: t("device.timeZone.description"),
fields: [
{
type: "text",
name: "tzdef",
label: t("device.posixTimezone.label"),
description: t("device.posixTimezone.description"),
properties: {
fieldLength: {
max: 64,
currentValueLength: effective?.tzdef?.length,
showCharacterCount: true,
},
},
},
],
},
{
label: t("device.gpio.label"),
description: t("device.gpio.description"),
fields: [
{
type: "number",
name: "buttonGpio",
label: t("device.buttonPin.label"),
description: t("device.buttonPin.description"),
},
{
type: "number",
name: "buzzerGpio",
label: t("device.buzzerPin.label"),
description: t("device.buzzerPin.description"),
},
],
},
]}
/>
);

View File

@@ -2,25 +2,34 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type DisplayValidation, DisplayValidationSchema } from "@app/validation/config/display.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { display?: Protobuf.Config.Config_DisplayConfig },
peek: () => ({}) as { display?: Protobuf.Config.Config_DisplayConfig },
subscribe: () => () => {},
} as const;
export const Display = ({ onFormInit }: DisplayConfigProps) => {
useWaitForConfig({ configCase: "display" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.display ??
(getEffectiveConfig("display") as Protobuf.Config.Config_DisplayConfig | undefined);
const { t } = useTranslation("config");
const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) {
removeChange({ type: "config", variant: "display" });
return;
}
setChange({ type: "config", variant: "display" }, data, config.display);
if (!editor) return;
editor.setRadioSection("display", data as unknown as Protobuf.Config.Config_DisplayConfig);
};
return (
@@ -29,43 +38,12 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema}
defaultValues={config.display}
values={getEffectiveConfig("display")}
values={effective}
fieldGroups={[
{
label: t("display.title"),
description: t("display.description"),
label: t("display.deviceDisplay.label"),
description: t("display.deviceDisplay.description"),
fields: [
{
type: "number",
name: "screenOnSecs",
label: t("display.screenTimeout.label"),
description: t("display.screenTimeout.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
// TODO: This field is deprecated since protobufs 2.7.4 and only has UNUSED=0 value.
// GPS format has been moved to DeviceUIConfig.gps_format with proper enum values (DEC, DMS, UTM, MGRS, OLC, OSGR, MLS).
// This should be removed once DeviceUI settings are implemented.
// See: packages/protobufs/meshtastic/device_ui.proto
{
type: "select",
name: "gpsFormat",
label: t("display.gpsDisplayUnits.label"),
description: t("display.gpsDisplayUnits.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DeprecatedGpsCoordinateFormat,
},
},
{
type: "number",
name: "autoScreenCarouselSecs",
label: t("display.carouselDelay.label"),
description: t("display.carouselDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "toggle",
name: "compassNorthTop",
@@ -80,9 +58,9 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
},
{
type: "toggle",
name: "flipScreen",
label: t("display.flipScreen.label"),
description: t("display.flipScreen.description"),
name: "headingBold",
label: t("display.headingBold.label"),
description: t("display.headingBold.description"),
},
{
type: "select",
@@ -94,6 +72,48 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
formatEnumName: true,
},
},
],
},
{
label: t("display.advanced.label"),
description: t("display.advanced.description"),
fields: [
{
type: "number",
name: "screenOnSecs",
label: t("display.screenTimeout.label"),
description: t("display.screenTimeout.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "autoScreenCarouselSecs",
label: t("display.carouselDelay.label"),
description: t("display.carouselDelay.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "wakeOnTapOrMotion",
label: t("display.wakeOnTapOrMotion.label"),
description: t("display.wakeOnTapOrMotion.description"),
},
{
type: "toggle",
name: "flipScreen",
label: t("display.flipScreen.label"),
description: t("display.flipScreen.description"),
},
{
type: "select",
name: "displaymode",
label: t("display.displayMode.label"),
description: t("display.displayMode.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
formatEnumName: true,
},
},
{
type: "select",
name: "oled",
@@ -105,26 +125,14 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
},
{
type: "select",
name: "displaymode",
label: t("display.displayMode.label"),
description: t("display.displayMode.description"),
name: "compassOrientation",
label: t("display.compassOrientation.label"),
description: t("display.compassOrientation.description"),
properties: {
enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode,
enumValue: Protobuf.Config.Config_DisplayConfig_CompassOrientation,
formatEnumName: true,
},
},
{
type: "toggle",
name: "headingBold",
label: t("display.headingBold.label"),
description: t("display.headingBold.description"),
},
{
type: "toggle",
name: "wakeOnTapOrMotion",
label: t("display.wakeOnTapOrMotion.label"),
description: t("display.wakeOnTapOrMotion.description"),
},
{
type: "toggle",
name: "enableMessageBubbles",

View File

@@ -2,26 +2,35 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type LoRaValidation, LoRaValidationSchema } from "@app/validation/config/lora.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { lora?: Protobuf.Config.Config_LoRaConfig },
peek: () => ({}) as { lora?: Protobuf.Config.Config_LoRaConfig },
subscribe: () => () => {},
} as const;
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
useWaitForConfig({ configCase: "lora" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effectiveLora =
radio.lora ?? (getEffectiveConfig("lora") as Protobuf.Config.Config_LoRaConfig | undefined);
const { t } = useTranslation("config");
const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) {
removeChange({ type: "config", variant: "lora" });
return;
}
setChange({ type: "config", variant: "lora" }, data, config.lora);
if (!editor) return;
editor.setRadioSection("lora", data as unknown as Protobuf.Config.Config_LoRaConfig);
};
return (
@@ -30,11 +39,11 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
defaultValues={config.lora}
values={getEffectiveConfig("lora")}
values={effectiveLora}
fieldGroups={[
{
label: t("lora.title"),
description: t("lora.description"),
label: t("lora.optionsCard.label"),
description: t("lora.optionsCard.description"),
fields: [
{
type: "select",
@@ -45,21 +54,52 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode,
},
},
{
type: "toggle",
name: "usePreset",
label: t("lora.usePreset.label"),
description: t("lora.usePreset.description"),
},
{
type: "select",
name: "hopLimit",
label: t("lora.hopLimit.label"),
description: t("lora.hopLimit.description"),
name: "modemPreset",
label: t("lora.modemPreset.label"),
description: t("lora.modemPreset.description"),
disabledBy: [{ fieldName: "usePreset" }],
properties: {
enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset,
formatEnumName: true,
},
},
{
type: "number",
name: "channelNum",
label: t("lora.frequencySlot.label"),
description: t("lora.frequencySlot.description"),
name: "bandwidth",
label: t("lora.bandwidth.label"),
description: t("lora.bandwidth.description"),
disabledBy: [{ fieldName: "usePreset", invert: true }],
properties: { suffix: t("unit.kilohertz") },
},
{
type: "number",
name: "spreadFactor",
label: t("lora.spreadingFactor.label"),
description: t("lora.spreadingFactor.description"),
disabledBy: [{ fieldName: "usePreset", invert: true }],
properties: { suffix: t("unit.cps") },
},
{
type: "number",
name: "codingRate",
label: t("lora.codingRate.label"),
description: t("lora.codingRate.description"),
disabledBy: [{ fieldName: "usePreset", invert: true }],
},
],
},
{
label: t("lora.advancedCard.label"),
description: t("lora.advancedCard.description"),
fields: [
{
type: "toggle",
name: "ignoreMqtt",
@@ -72,103 +112,33 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
label: t("lora.okToMqtt.label"),
description: t("lora.okToMqtt.description"),
},
],
},
{
label: t("lora.waveformSettings.label"),
description: t("lora.waveformSettings.description"),
fields: [
{
type: "toggle",
name: "usePreset",
label: t("lora.usePreset.label"),
description: t("lora.usePreset.description"),
},
{
type: "select",
name: "modemPreset",
label: t("lora.modemPreset.label"),
description: t("lora.modemPreset.description"),
disabledBy: [
{
fieldName: "usePreset",
},
],
properties: {
enumValue: Protobuf.Config.Config_LoRaConfig_ModemPreset,
formatEnumName: true,
},
},
{
type: "number",
name: "bandwidth",
label: t("lora.bandwidth.label"),
description: t("lora.bandwidth.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.kilohertz"),
},
},
{
type: "number",
name: "spreadFactor",
label: t("lora.spreadingFactor.label"),
description: t("lora.spreadingFactor.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
properties: {
suffix: t("unit.cps"),
},
},
{
type: "number",
name: "codingRate",
label: t("lora.codingRate.label"),
description: t("lora.codingRate.description"),
disabledBy: [
{
fieldName: "usePreset",
invert: true,
},
],
},
],
},
{
label: t("lora.radioSettings.label"),
description: t("lora.radioSettings.description"),
fields: [
{
type: "toggle",
name: "txEnabled",
label: t("lora.transmitEnabled.label"),
description: t("lora.transmitEnabled.description"),
},
{
type: "number",
name: "txPower",
label: t("lora.transmitPower.label"),
description: t("lora.transmitPower.description"),
properties: {
suffix: t("unit.dbm"),
},
},
{
type: "toggle",
name: "overrideDutyCycle",
label: t("lora.overrideDutyCycle.label"),
description: t("lora.overrideDutyCycle.description"),
},
{
type: "select",
name: "hopLimit",
label: t("lora.hopLimit.label"),
description: t("lora.hopLimit.description"),
properties: {
enumValue: { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 },
},
},
{
type: "number",
name: "channelNum",
label: t("lora.frequencySlot.label"),
description: t("lora.frequencySlot.description"),
},
{
type: "number",
name: "frequencyOffset",
@@ -194,6 +164,13 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
step: 0.001,
},
},
{
type: "number",
name: "txPower",
label: t("lora.transmitPower.label"),
description: t("lora.transmitPower.description"),
properties: { suffix: t("unit.dbm") },
},
{
type: "select",
name: "femLnaMode",

View File

@@ -3,23 +3,47 @@ import { type NetworkValidation, NetworkValidationSchema } from "@app/validation
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { convertIntToIpAddress, convertIpAddressToInt } from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { network?: Protobuf.Config.Config_NetworkConfig },
peek: () => ({}) as { network?: Protobuf.Config.Config_NetworkConfig },
subscribe: () => () => {},
} as const;
const toFormShape = (cfg: Protobuf.Config.Config_NetworkConfig | undefined) => ({
...cfg,
ipv4Config: {
ip: convertIntToIpAddress(cfg?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(cfg?.ipv4Config?.gateway ?? 0),
subnet: convertIntToIpAddress(cfg?.ipv4Config?.subnet ?? 0),
dns: convertIntToIpAddress(cfg?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
cfg?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
});
export const Network = ({ onFormInit }: NetworkConfigProps) => {
useWaitForConfig({ configCase: "network" });
const { config, setChange, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.network ??
(getEffectiveConfig("network") as Protobuf.Config.Config_NetworkConfig | undefined);
const { t } = useTranslation("config");
const networkConfig = getEffectiveConfig("network");
const onSubmit = (data: NetworkValidation) => {
if (!editor) return;
const payload = {
...data,
ipv4Config: create(Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema, {
@@ -29,49 +53,20 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
}),
};
if (deepCompareConfig(config.network, payload, true)) {
removeChange({ type: "config", variant: "network" });
return;
}
setChange({ type: "config", variant: "network" }, payload, config.network);
editor.setRadioSection("network", payload as unknown as Protobuf.Config.Config_NetworkConfig);
};
return (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
defaultValues={{
...config.network,
ipv4Config: {
ip: convertIntToIpAddress(config.network?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(config.network?.ipv4Config?.gateway ?? 0),
subnet: convertIntToIpAddress(config.network?.ipv4Config?.subnet ?? 0),
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={
{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(networkConfig?.ipv4Config?.gateway ?? 0),
subnet: convertIntToIpAddress(networkConfig?.ipv4Config?.subnet ?? 0),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols:
networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation
}
defaultValues={toFormShape(config.network) as NetworkValidation}
values={toFormShape(effective) as NetworkValidation}
fieldGroups={[
{
label: t("network.title"),
description: t("network.description"),
label: t("network.wifiOptions.label"),
description: t("network.wifiOptions.description"),
notes: t("network.note"),
fields: [
{
@@ -85,28 +80,20 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
name: "wifiSsid",
label: t("network.ssid.label"),
description: t("network.ssid.label"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
disabledBy: [{ fieldName: "wifiEnabled" }],
},
{
type: "password",
name: "wifiPsk",
label: t("network.psk.label"),
description: t("network.psk.description"),
disabledBy: [
{
fieldName: "wifiEnabled",
},
],
disabledBy: [{ fieldName: "wifiEnabled" }],
},
],
},
{
label: t("network.ethernetConfigSettings.label"),
description: t("network.ethernetConfigSettings.description"),
label: t("network.ethernetOptionsCard.label"),
description: t("network.ethernetOptionsCard.description"),
fields: [
{
type: "toggle",
@@ -117,9 +104,29 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
],
},
{
label: t("network.ipConfigSettings.label"),
description: t("network.ipConfigSettings.description"),
label: t("network.advancedCard.label"),
description: t("network.advancedCard.description"),
fields: [
{
type: "text",
name: "ntpServer",
label: t("network.ntpServer.label"),
},
{
type: "text",
name: "rsyslogServer",
label: t("network.rsyslogServer.label"),
},
{
type: "select",
name: "enabledProtocols",
label: t("network.meshViaUdp.label"),
description: t("network.meshViaUdp.description"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
},
},
{
type: "select",
name: "addressMode",
@@ -179,43 +186,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
},
],
},
{
label: t("network.udpConfigSettings.label"),
description: t("network.udpConfigSettings.description"),
fields: [
{
type: "select",
name: "enabledProtocols",
label: t("network.meshViaUdp.label"),
properties: {
enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
formatEnumName: true,
},
},
],
},
{
label: t("network.ntpConfigSettings.label"),
description: t("network.ntpConfigSettings.description"),
fields: [
{
type: "text",
name: "ntpServer",
label: t("network.ntpServer.label"),
},
],
},
{
label: t("network.rsyslogConfigSettings.label"),
description: t("network.rsyslogConfigSettings.description"),
fields: [
{
type: "text",
name: "rsyslogServer",
label: t("network.rsyslogServer.label"),
},
],
},
]}
/>
);

View File

@@ -5,58 +5,116 @@ import {
} from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { Button } from "@components/UI/Button.tsx";
import { type FlagName, usePositionFlags } from "@core/hooks/usePositionFlags.ts";
import { useDevice, useNodeDB } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback, useMemo } from "react";
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { LocateFixed } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
/**
* Renders inside the Device GPS card. Pulls the browser's current location
* via navigator.geolocation and writes lat/lng/altitude into the form.
* No-op without a geolocation API (e.g. insecure context).
*/
function UseBrowserLocationButton() {
const { setValue } = useFormContext<PositionValidation>();
const { toast } = useToast();
const { t } = useTranslation("config");
const [busy, setBusy] = useState(false);
if (typeof navigator === "undefined" || !navigator.geolocation) {
return null;
}
const onClick = (): void => {
setBusy(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setBusy(false);
setValue("latitude", Number(pos.coords.latitude.toFixed(7)), { shouldDirty: true });
setValue("longitude", Number(pos.coords.longitude.toFixed(7)), { shouldDirty: true });
if (pos.coords.altitude !== null && !Number.isNaN(pos.coords.altitude)) {
setValue("altitude", Math.round(pos.coords.altitude), { shouldDirty: true });
}
},
(err) => {
setBusy(false);
toast({
title: t("position.useBrowserLocation.failed", "Could not read browser location"),
description: err.message,
});
},
{ enableHighAccuracy: true, timeout: 10_000 },
);
};
return (
<Button type="button" variant="subtle" disabled={busy} onClick={onClick}>
<LocateFixed className="mr-2 size-4" />
{busy
? t("position.useBrowserLocation.busy", "Reading location…")
: t("position.useBrowserLocation.label", "Use browser location")}
</Button>
);
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { position?: Protobuf.Config.Config_PositionConfig },
peek: () => ({}) as { position?: Protobuf.Config.Config_PositionConfig },
subscribe: () => () => {},
} as const;
export const Position = ({ onFormInit }: PositionConfigProps) => {
useWaitForConfig({ configCase: "position" });
const { setChange, config, getEffectiveConfig, removeChange, queueAdminMessage } = useDevice();
const { getMyNode } = useNodeDB();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const myNode = useMyNodeAsProto();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effectivePosition =
radio.position ??
(getEffectiveConfig("position") as Protobuf.Config.Config_PositionConfig | undefined);
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
getEffectiveConfig("position")?.positionFlags ?? 0,
effectivePosition?.positionFlags ?? 0,
);
const { t } = useTranslation("config");
const myNode = getMyNode();
const currentPosition = myNode?.position;
const effectiveConfig = getEffectiveConfig("position");
const displayUnits = getEffectiveConfig("display")?.units;
const formValues = useMemo(() => {
return {
...config.position,
...effectiveConfig,
// Include current position coordinates if available
...effectivePosition,
latitude: currentPosition?.latitudeI ? currentPosition.latitudeI / 1e7 : undefined,
longitude: currentPosition?.longitudeI ? currentPosition.longitudeI / 1e7 : undefined,
altitude: currentPosition?.altitude ?? 0,
} as PositionValidation;
}, [config.position, effectiveConfig, currentPosition]);
}, [config.position, effectivePosition, currentPosition]);
const onSubmit = (data: PositionValidation) => {
// Exclude position coordinates from config payload (they're handled via admin message)
const { latitude: _latitude, longitude: _longitude, altitude: _altitude, ...configData } = data;
const payload = { ...configData, positionFlags: flagsValue };
// Save config first
let configResult: ReturnType<typeof setChange> | undefined;
if (deepCompareConfig(config.position, payload, true)) {
removeChange({ type: "config", variant: "position" });
configResult = undefined;
} else {
configResult = setChange({ type: "config", variant: "position" }, payload, config.position);
if (editor) {
editor.setRadioSection(
"position",
payload as unknown as Protobuf.Config.Config_PositionConfig,
);
}
// Then handle position coordinates via admin message if fixedPosition is enabled
if (data.fixedPosition && data.latitude !== undefined && data.longitude !== undefined) {
const message = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
@@ -70,10 +128,8 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
},
});
queueAdminMessage(message);
editor?.queueAdminMessage(message);
}
return configResult;
};
const onPositonFlagChange = useCallback(
@@ -87,7 +143,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
<DynamicForm<PositionValidation>
onSubmit={(data) => {
data.positionFlags = flagsValue;
return onSubmit(data);
onSubmit(data);
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
@@ -95,9 +151,16 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
values={formValues}
fieldGroups={[
{
label: t("position.title"),
description: t("position.description"),
label: t("position.positionPacket.label"),
description: t("position.positionPacket.description"),
fields: [
{
type: "number",
name: "positionBroadcastSecs",
label: t("position.broadcastInterval.label"),
description: t("position.broadcastInterval.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "toggle",
name: "positionBroadcastSmartEnabled",
@@ -105,27 +168,33 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
description: t("position.smartPositionEnabled.description"),
},
{
type: "select",
name: "gpsMode",
label: t("position.gpsMode.label"),
description: t("position.gpsMode.description"),
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
type: "number",
name: "broadcastSmartMinimumIntervalSecs",
label: t("position.smartPositionMinInterval.label"),
description: t("position.smartPositionMinInterval.description"),
properties: { suffix: t("unit.second.plural") },
disabledBy: [{ fieldName: "positionBroadcastSmartEnabled" }],
},
{
type: "number",
name: "broadcastSmartMinimumDistance",
label: t("position.smartPositionMinDistance.label"),
description: t("position.smartPositionMinDistance.description"),
disabledBy: [{ fieldName: "positionBroadcastSmartEnabled" }],
},
],
},
{
label: t("position.deviceGps.label"),
description: t("position.deviceGps.description"),
footer: <UseBrowserLocationButton />,
fields: [
{
type: "toggle",
name: "fixedPosition",
label: t("position.fixedPosition.label"),
description: t("position.fixedPosition.description"),
disabledBy: [
{
fieldName: "gpsMode",
selector: Protobuf.Config.Config_PositionConfig_GpsMode.ENABLED,
},
],
},
// Position coordinate fields (only shown when fixedPosition is enabled)
{
type: "number",
name: "latitude",
@@ -134,15 +203,9 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
properties: {
step: 0.0000001,
suffix: "Degrees",
fieldLength: {
max: 10,
},
fieldLength: { max: 10 },
},
disabledBy: [
{
fieldName: "fixedPosition",
},
],
disabledBy: [{ fieldName: "fixedPosition" }],
},
{
type: "number",
@@ -152,15 +215,9 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
properties: {
step: 0.0000001,
suffix: "Degrees",
fieldLength: {
max: 10,
},
fieldLength: { max: 10 },
},
disabledBy: [
{
fieldName: "fixedPosition",
},
],
disabledBy: [{ fieldName: "fixedPosition" }],
},
{
type: "number",
@@ -179,12 +236,32 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
? "Feet"
: "Meters",
},
disabledBy: [
{
fieldName: "fixedPosition",
},
],
disabledBy: [{ fieldName: "fixedPosition" }],
},
{
type: "select",
name: "gpsMode",
label: t("position.gpsMode.label"),
description: t("position.gpsMode.description"),
properties: {
enumValue: Protobuf.Config.Config_PositionConfig_GpsMode,
},
disabledBy: [{ fieldName: "fixedPosition", invert: true }],
},
{
type: "number",
name: "gpsUpdateInterval",
label: t("position.gpsUpdateInterval.label"),
description: t("position.gpsUpdateInterval.description"),
properties: { suffix: t("unit.second.plural") },
disabledBy: [{ fieldName: "fixedPosition", invert: true }],
},
],
},
{
label: t("position.positionFlags.label"),
description: t("position.positionFlags.description"),
fields: [
{
type: "multiSelect",
name: "positionFlags",
@@ -198,6 +275,12 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
enumValue: getAllFlags(),
},
},
],
},
{
label: t("position.advancedDeviceGps.label"),
description: t("position.advancedDeviceGps.description"),
fields: [
{
type: "number",
name: "rxGpio",
@@ -218,52 +301,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
},
],
},
{
label: t("position.intervalsSettings.label"),
description: t("position.intervalsSettings.description"),
fields: [
{
type: "number",
name: "positionBroadcastSecs",
label: t("position.broadcastInterval.label"),
description: t("position.broadcastInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "gpsUpdateInterval",
label: t("position.gpsUpdateInterval.label"),
description: t("position.gpsUpdateInterval.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "broadcastSmartMinimumDistance",
label: t("position.smartPositionMinDistance.label"),
description: t("position.smartPositionMinDistance.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
{
type: "number",
name: "broadcastSmartMinimumIntervalSecs",
label: t("position.smartPositionMinInterval.label"),
description: t("position.smartPositionMinInterval.description"),
disabledBy: [
{
fieldName: "positionBroadcastSmartEnabled",
},
],
},
],
},
]}
/>
);

View File

@@ -2,25 +2,34 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import { type PowerValidation, PowerValidationSchema } from "@app/validation/config/power.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useTranslation } from "react-i18next";
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { power?: Protobuf.Config.Config_PowerConfig },
peek: () => ({}) as { power?: Protobuf.Config.Config_PowerConfig },
subscribe: () => () => {},
} as const;
export const Power = ({ onFormInit }: PowerConfigProps) => {
useWaitForConfig({ configCase: "power" });
const { setChange, config, getEffectiveConfig, removeChange } = useDevice();
const { config, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.power ?? (getEffectiveConfig("power") as Protobuf.Config.Config_PowerConfig | undefined);
const { t } = useTranslation("config");
const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) {
removeChange({ type: "config", variant: "power" });
return;
}
setChange({ type: "config", variant: "power" }, data, config.power);
if (!editor) return;
editor.setRadioSection("power", data as unknown as Protobuf.Config.Config_PowerConfig);
};
return (
@@ -29,11 +38,11 @@ export const Power = ({ onFormInit }: PowerConfigProps) => {
onFormInit={onFormInit}
validationSchema={PowerValidationSchema}
defaultValues={config.power}
values={getEffectiveConfig("power")}
values={effective}
fieldGroups={[
{
label: t("power.powerConfigSettings.label"),
description: t("power.powerConfigSettings.description"),
label: t("power.powerConfig.label"),
description: t("power.powerConfig.description"),
fields: [
{
type: "toggle",
@@ -46,27 +55,35 @@ export const Power = ({ onFormInit }: PowerConfigProps) => {
name: "onBatteryShutdownAfterSecs",
label: t("power.shutdownOnBatteryDelay.label"),
description: t("power.shutdownOnBatteryDelay.description"),
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "adcMultiplierOverride",
label: t("power.adcMultiplierOverride.label"),
description: t("power.adcMultiplierOverride.description"),
properties: {
step: 0.0001,
},
properties: { step: 0.0001 },
},
{
type: "number",
name: "waitBluetoothSecs",
label: t("power.noConnectionBluetoothDisabled.label"),
description: t("power.noConnectionBluetoothDisabled.description"),
properties: {
suffix: t("unit.second.plural"),
},
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "sdsSecs",
label: t("power.superDeepSleepDuration.label"),
description: t("power.superDeepSleepDuration.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
name: "minWakeSecs",
label: t("power.minimumWakeTime.label"),
description: t("power.minimumWakeTime.description"),
properties: { suffix: t("unit.second.plural") },
},
{
type: "number",
@@ -76,39 +93,6 @@ export const Power = ({ onFormInit }: PowerConfigProps) => {
},
],
},
{
label: t("power.sleepSettings.label"),
description: t("power.sleepSettings.description"),
fields: [
{
type: "number",
name: "sdsSecs",
label: t("power.superDeepSleepDuration.label"),
description: t("power.superDeepSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "lsSecs",
label: t("power.lightSleepDuration.label"),
description: t("power.lightSleepDuration.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
{
type: "number",
name: "minWakeSecs",
label: t("power.minimumWakeTime.label"),
description: t("power.minimumWakeTime.description"),
properties: {
suffix: t("unit.second.plural"),
},
},
],
},
]}
/>
);

View File

@@ -9,8 +9,9 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { type DefaultValues, useForm } from "react-hook-form";
@@ -19,48 +20,43 @@ import { useTranslation } from "react-i18next";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
const EMPTY_RADIO_SIGNAL = {
value: {} as { security?: Protobuf.Config.Config_SecurityConfig },
peek: () => ({}) as { security?: Protobuf.Config.Config_SecurityConfig },
subscribe: () => () => {},
} as const;
const toFormShape = (cfg: Protobuf.Config.Config_SecurityConfig | undefined) => ({
...cfg,
privateKey: fromByteArray(cfg?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(cfg?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(cfg?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(cfg?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(cfg?.adminKey?.at(2) ?? new Uint8Array(0)),
],
});
export const Security = ({ onFormInit }: SecurityConfigProps) => {
useWaitForConfig({ configCase: "security" });
const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } = useDevice();
const { config, setDialogOpen, getEffectiveConfig } = useDevice();
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);
const effective =
radio.security ??
(getEffectiveConfig("security") as Protobuf.Config.Config_SecurityConfig | undefined);
const { t } = useTranslation("config");
const defaultConfig = config.security;
const defaultValues = {
...defaultConfig,
...{
privateKey: fromByteArray(defaultConfig?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(defaultConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(defaultConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(defaultConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
const effectiveConfig = getEffectiveConfig("security");
const formValues = {
...effectiveConfig,
...{
privateKey: fromByteArray(effectiveConfig?.privateKey ?? new Uint8Array(0)),
publicKey: fromByteArray(effectiveConfig?.publicKey ?? new Uint8Array(0)),
adminKey: [
fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
],
},
};
const formMethods = useForm<RawSecurity>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<RawSecurity>,
defaultValues: toFormShape(config.security) as DefaultValues<RawSecurity>,
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values: formValues as RawSecurity,
values: toFormShape(effective) as RawSecurity,
});
const { setValue, trigger, handleSubmit, formState } = formMethods;
@@ -72,9 +68,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(false);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) {
return;
}
if (!formState.isReady || !editor) return;
const payload: ParsedSecurity = {
...data,
@@ -86,13 +80,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
toByteArray(data.adminKey.at(2) ?? ""),
],
};
if (deepCompareConfig(config.security, payload, true)) {
removeChange({ type: "config", variant: "security" });
return;
}
setChange({ type: "config", variant: "security" }, payload, config.security);
editor.setRadioSection("security", payload as unknown as Protobuf.Config.Config_SecurityConfig);
};
const pkiRegenerate = () => {
@@ -112,7 +100,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
}
const valid = await trigger(["privateKey", "publicKey"]);
if (valid) {
handleSubmit(onSubmit)(); // manually invoke form submit
handleSubmit(onSubmit)();
}
};
@@ -131,8 +119,8 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
onSubmit={onSubmit}
fieldGroups={[
{
label: t("security.title"),
description: t("security.description"),
label: t("security.directMessageKey.label"),
description: t("security.directMessageKey.description"),
fields: [
{
type: "passwordGenerator",
@@ -176,8 +164,8 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
],
},
{
label: t("security.adminSettings.label"),
description: t("security.adminSettings.description"),
label: t("security.adminKeysCard.label"),
description: t("security.adminKeysCard.description"),
fields: [
{
type: "passwordGenerator",
@@ -189,9 +177,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
properties: { showCopyButton: true },
},
{
type: "passwordGenerator",
@@ -203,9 +189,7 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
properties: { showCopyButton: true },
},
{
type: "passwordGenerator",
@@ -217,10 +201,32 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
devicePSKBitCount: 32,
actionButtons: [],
disabledBy: [{ fieldName: "adminChannelEnabled", invert: true }],
properties: {
showCopyButton: true,
},
properties: { showCopyButton: true },
},
],
},
{
label: t("security.logsCard.label"),
description: t("security.logsCard.description"),
fields: [
{
type: "toggle",
name: "serialEnabled",
label: t("security.serialOutputEnabled.label"),
description: t("security.serialOutputEnabled.description"),
},
{
type: "toggle",
name: "debugLogApiEnabled",
label: t("security.enableDebugLogApi.label"),
description: t("security.enableDebugLogApi.description"),
},
],
},
{
label: t("security.administration.label"),
description: t("security.administration.description"),
fields: [
{
type: "toggle",
name: "isManaged",
@@ -230,7 +236,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
if (checked) {
setManagedModeDialogOpen(true);
}
setValue("isManaged", false);
},
},
@@ -242,24 +247,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
},
],
},
{
label: t("security.loggingSettings.label"),
description: t("security.loggingSettings.description"),
fields: [
{
type: "toggle",
name: "debugLogApiEnabled",
label: t("security.enableDebugLogApi.label"),
description: t("security.enableDebugLogApi.description"),
},
{
type: "toggle",
name: "serialEnabled",
label: t("security.serialOutputEnabled.label"),
description: t("security.serialOutputEnabled.description"),
},
],
},
]}
/>
<PkiRegenerateDialog

View File

@@ -1,73 +1,105 @@
import { type UserValidation, UserValidationSchema } from "@app/validation/config/user.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice, useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
interface UserConfigProps {
onFormInit: DynamicFormFormInit<UserValidation>;
}
const EMPTY_OWNER_SIGNAL = {
value: undefined as Protobuf.Mesh.User | undefined,
peek: () => undefined as Protobuf.Mesh.User | undefined,
subscribe: () => () => {},
} as const;
export const User = ({ onFormInit }: UserConfigProps) => {
const { hardware, getChange, connection } = useDevice();
const { getNode } = useNodeDB();
const { t } = useTranslation("config");
const editor = useConfigEditor();
const myNode = useMyNodeAsProto();
const owner = useSignal(editor?.owner ?? EMPTY_OWNER_SIGNAL);
const myNode = getNode(hardware.myNodeNum);
const defaultUser = myNode?.user ?? {
id: "",
longName: "",
shortName: "",
isLicensed: false,
};
// Seed the editor's baseline when the device's user info first arrives.
// setBaselineOwner is a no-op when the working copy is dirty, so user
// edits in flight are preserved across re-renders.
useEffect(() => {
if (editor && myNode?.user) {
editor.setBaselineOwner(myNode.user);
}
}, [editor, myNode?.user]);
// Get working copy from change registry
const workingUser = getChange({ type: "user" }) as Protobuf.Mesh.User | undefined;
const effectiveUser = workingUser ?? defaultUser;
const baselineUser =
myNode?.user ??
create(Protobuf.Mesh.UserSchema, {
id: "",
longName: "",
shortName: "",
isLicensed: false,
});
const effectiveUser = owner ?? baselineUser;
const onSubmit = (data: UserValidation) => {
connection?.setOwner(
create(Protobuf.Mesh.UserSchema, {
...data,
}),
);
if (!editor) return;
// Preserve fields the form doesn't edit (id, hwModel, role, publicKey, etc.).
const merged = create(Protobuf.Mesh.UserSchema, {
...baselineUser,
longName: data.longName,
shortName: data.shortName,
isUnmessagable: data.isUnmessageable,
isLicensed: data.isLicensed,
});
editor.setOwner(merged);
};
const hwName = Protobuf.Mesh.HardwareModel[baselineUser.hwModel] ?? String(baselineUser.hwModel);
return (
<DynamicForm<UserValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={UserValidationSchema}
defaultValues={{
longName: defaultUser.longName,
shortName: defaultUser.shortName,
isLicensed: defaultUser.isLicensed,
isUnmessageable: false,
nodeId: baselineUser.id,
hardwareModel: hwName,
longName: baselineUser.longName,
shortName: baselineUser.shortName,
isLicensed: baselineUser.isLicensed,
isUnmessageable: baselineUser.isUnmessagable ?? false,
}}
values={{
nodeId: effectiveUser.id,
hardwareModel: hwName,
longName: effectiveUser.longName,
shortName: effectiveUser.shortName,
isLicensed: effectiveUser.isLicensed,
isUnmessageable: false,
isUnmessageable: effectiveUser.isUnmessagable ?? false,
}}
fieldGroups={[
{
label: t("user.title"),
description: t("user.description"),
label: t("user.userConfig.label"),
description: t("user.userConfig.description"),
fields: [
{
type: "text",
name: "nodeId",
label: t("user.nodeId.label"),
description: t("user.nodeId.description"),
disabled: true,
properties: {
showCopyButton: true,
},
},
{
type: "text",
name: "longName",
label: t("user.longName.label"),
description: t("user.longName.description"),
properties: {
fieldLength: {
min: 1,
max: 40,
showCharacterCount: true,
},
fieldLength: { min: 1, max: 39, showCharacterCount: true },
},
},
{
@@ -76,13 +108,16 @@ export const User = ({ onFormInit }: UserConfigProps) => {
label: t("user.shortName.label"),
description: t("user.shortName.description"),
properties: {
fieldLength: {
min: 2,
max: 4,
showCharacterCount: true,
},
fieldLength: { min: 1, max: 4, showCharacterCount: true },
},
},
{
type: "text",
name: "hardwareModel",
label: t("user.hardwareModel.label"),
description: t("user.hardwareModel.description"),
disabled: true,
},
{
type: "toggle",
name: "isUnmessageable",

View File

@@ -0,0 +1,64 @@
import { Button } from "@components/UI/Button.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useIsRegionUnset } from "@meshtastic/sdk-react";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
/**
* Mirrors Meshtastic-Android's `regionUnset` cue. A freshly-flashed device
* has `Config.LoRa.region == UNSET` until the user picks one, and won't
* transmit at all in that state. We surface a non-dismissable toast that
* deep-links into the LoRa settings tab so first-time users land in the
* right place without hunting through the menu.
*
* The toast appears as soon as the SDK reports `isRegionUnset == true`
* (after the LoRa config packet arrives) and dismisses itself the moment
* a real region is committed.
*/
export const RegionSetupReminder = (): null => {
const isRegionUnset = useIsRegionUnset();
const { toast } = useToast();
const navigate = useNavigate();
const { t } = useTranslation("dialog");
const dismissRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!isRegionUnset) {
dismissRef.current?.();
dismissRef.current = null;
return;
}
if (dismissRef.current) return;
const { dismiss } = toast({
title: t("regionSetup.title", { defaultValue: "Set your region" }),
description: t("regionSetup.description", {
defaultValue:
"This device has no LoRa region configured and won't transmit until you pick one.",
}),
duration: Number.POSITIVE_INFINITY,
action: (
<Button
type="button"
variant="default"
className="w-full"
onClick={() => {
dismiss();
navigate({ to: "/settings/radio" });
}}
>
{t("regionSetup.cta", { defaultValue: "Set region" })}
</Button>
),
});
dismissRef.current = dismiss;
return () => {
dismiss();
dismissRef.current = null;
};
}, [isRegionUnset, toast, navigate, t]);
return null;
};

View File

@@ -3,16 +3,17 @@ import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useMyNodeAsProto, useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
import {
type Page,
useActiveConnection,
useAppStore,
useDefaultConnection,
useDevice,
useNodeDB,
useSidebar,
} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useTotalUnread } from "@meshtastic/sdk-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
CircleChevronLeft,
@@ -67,10 +68,12 @@ const CollapseToggleButton = () => {
};
export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
const { getNode, getNodesLength } = useNodeDB();
const { metadata, setDialogOpen } = useDevice();
const numUnread = useTotalUnread();
const allNodes = useNodesAsProto();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const myNode = useMyNodeAsProto();
const getNodesLength = () => allNodes.length;
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
@@ -89,8 +92,6 @@ export const Sidebar = ({ children }: SidebarProps) => {
const myMetadata = metadata.get(0);
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
Math.max(getNodesLength() - 1, 0),
);

View File

@@ -1,4 +1,4 @@
import { useNodeDB } from "@app/core/stores";
import { useNodeAsProto } from "@app/core/hooks/useNodesAsProto.ts";
import { getColorFromNodeNum, isLightColor } from "@app/core/utils/color";
import {
Tooltip,
@@ -28,8 +28,7 @@ export const Avatar = ({
className,
}: AvatarProps) => {
const { t } = useTranslation();
const { getNode } = useNodeDB();
const node = getNode(nodeNum);
const node = useNodeAsProto(nodeNum);
if (!nodeNum) {
return null;

View File

@@ -11,7 +11,7 @@ import { Input } from "@components/UI/Input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "@components/UI/Popover.tsx";
import { cn } from "@core/utils/cn.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { FunnelIcon } from "lucide-react";
import {
type ComponentProps,

View File

@@ -1,5 +1,5 @@
import { useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
@@ -13,6 +13,7 @@ function createMockNode(): Protobuf.Mesh.NodeInfo {
viaMqtt: false,
isFavorite: true,
isIgnored: false,
isMuted: false,
hopsAway: 2,
isKeyManuallyVerified: false,
user: {

View File

@@ -1,4 +1,4 @@
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { useCallback, useMemo } from "react";

View File

@@ -112,17 +112,17 @@ describe("Generic Table", () => {
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]);
// Click "Short Name" to sort asc
fireEvent.click(columnHeaders[0]);
fireEvent.click(columnHeaders[0]!);
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]);
// Click "Short Name" again to sort desc
fireEvent.click(columnHeaders[0]);
fireEvent.click(columnHeaders[0]!);
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]);
// Click "Connection" to sort by hops asc
fireEvent.click(columnHeaders[2]);
fireEvent.click(columnHeaders[2]!);
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]);
});

View File

@@ -64,6 +64,7 @@ export const Table = ({ headings, rows }: TableProps) => {
const aCell = a.cells[columnIndex];
const bCell = b.cells[columnIndex];
if (!aCell || !bCell) return 0;
let aValue: string | number;
let bValue: string | number;

View File

@@ -0,0 +1,45 @@
import type { ConnectionId } from "@core/stores/deviceStore/types";
import type { MeshDevice } from "@meshtastic/sdk";
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (post-config)
const CONFIG_HEARTBEAT_INTERVAL_MS = 5_000; // 5s (during initial config)
const heartbeats = new Map<ConnectionId, ReturnType<typeof setInterval>>();
/**
* Stops + clears any active heartbeat for the connection. Safe to call when
* no heartbeat is running.
*/
export function stopHeartbeat(id: ConnectionId): void {
const h = heartbeats.get(id);
if (!h) return;
clearInterval(h);
heartbeats.delete(id);
}
/**
* Fast-cadence heartbeat used while the device is in `configuring`. Replaced
* by the maintenance heartbeat once the device fires onConfigComplete.
*/
export function startConfigHeartbeat(id: ConnectionId, meshDevice: MeshDevice): void {
stopHeartbeat(id);
const intervalId = setInterval(() => {
meshDevice.heartbeat().catch((error) => {
console.warn("[heartbeat] config heartbeat failed:", error);
});
}, CONFIG_HEARTBEAT_INTERVAL_MS);
heartbeats.set(id, intervalId);
}
/**
* Slow-cadence keep-alive used after configuration completes.
*/
export function startMaintenanceHeartbeat(id: ConnectionId, meshDevice: MeshDevice): void {
stopHeartbeat(id);
const intervalId = setInterval(() => {
meshDevice.heartbeat().catch((error) => {
console.warn("[heartbeat] maintenance heartbeat failed:", error);
});
}, HEARTBEAT_INTERVAL_MS);
heartbeats.set(id, intervalId);
}

View File

@@ -0,0 +1,82 @@
import { coordinator, getStorageDb } from "@core/sdkStorage.ts";
import type { ConnectionId } from "@core/stores/deviceStore/types";
import { createLogger, MeshDevice } from "@meshtastic/sdk";
import {
SqlocalDraftRepository,
SqlocalMessageRepository,
} from "@meshtastic/sdk-storage-sqlocal/chat";
import { SqlocalNodesRepository } from "@meshtastic/sdk-storage-sqlocal/nodes";
import { SqlocalTelemetryRepository } from "@meshtastic/sdk-storage-sqlocal/telemetry";
import type { TransportHTTP } from "@meshtastic/transport-http";
import type { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import type { TransportWebSerial } from "@meshtastic/transport-web-serial";
const log = createLogger("sdkClient");
export type AnyTransport = TransportHTTP | TransportWebBluetooth | TransportWebSerial;
const CHAT_RETENTION = { maxPerBucket: 1000, olderThanMs: 1000 * 60 * 60 * 24 * 90 } as const;
const TELEMETRY_RETENTION = { maxPerNode: 500, olderThanMs: 1000 * 60 * 60 * 24 * 30 } as const;
const STORAGE_OPEN_TIMEOUT_MS = 5000;
/**
* Builds a MeshDevice wired with persistence repositories opened against the
* shared OPFS-backed SQLite DB. If sqlocal is unavailable, hangs longer than
* `STORAGE_OPEN_TIMEOUT_MS`, or fails for any reason, falls through to the
* SDK's in-memory repositories so the connect path is never blocked by
* persistence.
*/
export async function buildMeshDevice(
connectionId: ConnectionId,
deviceId: number,
transport: AnyTransport,
): Promise<MeshDevice> {
log.debug("buildMeshDevice: enter", { connectionId, deviceId });
let chatRepository: SqlocalMessageRepository | undefined;
let draftRepository: SqlocalDraftRepository | undefined;
let nodesRepository: SqlocalNodesRepository | undefined;
let telemetryRepository: SqlocalTelemetryRepository | undefined;
try {
const t0 = Date.now();
const db = await Promise.race([
getStorageDb(),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(`sqlocal DB open did not resolve within ${STORAGE_OPEN_TIMEOUT_MS}ms`),
),
STORAGE_OPEN_TIMEOUT_MS,
),
),
]);
log.debug("buildMeshDevice: storage DB ready", { ms: Date.now() - t0 });
chatRepository = new SqlocalMessageRepository(db, { deviceId: connectionId, coordinator });
draftRepository = new SqlocalDraftRepository(db, { deviceId: connectionId });
nodesRepository = new SqlocalNodesRepository(db, { deviceId: connectionId });
telemetryRepository = new SqlocalTelemetryRepository(db, { deviceId: connectionId });
log.debug("buildMeshDevice: repositories opened");
} catch (err) {
const e = err as Error;
log.warn("buildMeshDevice: sqlocal unavailable, falling back to in-memory", {
name: e?.name,
message: e?.message,
});
}
return new MeshDevice(transport, {
configId: deviceId,
chat:
chatRepository || draftRepository
? {
repository: chatRepository,
draftRepository,
retention: CHAT_RETENTION,
}
: undefined,
nodes: nodesRepository ? { repository: nodesRepository } : undefined,
telemetry: telemetryRepository
? { repository: telemetryRepository, retention: TELEMETRY_RETENTION }
: undefined,
});
}

View File

@@ -0,0 +1,263 @@
import type { Connection } from "@core/stores/deviceStore/types";
import { testHttpReachable } from "@pages/Connections/utils";
import { createLogger } from "@meshtastic/sdk";
import { TransportHTTP } from "@meshtastic/transport-http";
import { BluetoothConnectError, TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
import { SerialConnectError, TransportWebSerial } from "@meshtastic/transport-web-serial";
import { Result } from "better-result";
import type { AnyTransport } from "./sdkClient.ts";
const log = createLogger("transports");
/**
* Per-transport-type factories. Each resolves a Transport from a saved
* Connection record + an optional cached BluetoothDevice/SerialPort the
* caller has held onto across reconnects.
*/
export interface OpenTransportOptions {
/** Whether the user explicitly initiated this connection (allows BT/Serial pickers). */
allowPrompt?: boolean;
/** Cached BT device from a prior connect, if any. */
cachedBluetoothDevice?: BluetoothDevice;
/** Cached serial port from a prior connect, if any. */
cachedSerialPort?: SerialPort;
}
export interface OpenTransportResult {
transport: AnyTransport;
/** BT device (when type=bluetooth) so caller can cache for cleanup + reconnect. */
bluetoothDevice?: BluetoothDevice;
/** Serial port (when type=serial) so caller can cache for cleanup + reconnect. */
serialPort?: SerialPort;
}
export async function openTransport(
conn: Connection,
opts: OpenTransportOptions = {},
): Promise<OpenTransportResult> {
switch (conn.type) {
case "http":
return openHttp(conn);
case "bluetooth":
return openBluetooth(conn, opts);
case "serial":
return openSerial(conn, opts);
default: {
const _exhaustive: never = conn;
void _exhaustive;
throw new Error(`Unknown transport type: ${(conn as { type?: string }).type}`);
}
}
}
async function openHttp(
conn: Connection & { type: "http"; url: string },
): Promise<OpenTransportResult> {
const ok = await testHttpReachable(conn.url);
if (!ok) {
const url = new URL(conn.url);
const isHTTPS = url.protocol === "https:";
throw new Error(
isHTTPS
? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
: "HTTP endpoint not reachable (may be blocked by CORS)",
);
}
const url = new URL(conn.url);
const isTLS = url.protocol === "https:";
const transport = await TransportHTTP.create(url.host, isTLS);
return { transport };
}
async function openBluetooth(
conn: Connection & {
type: "bluetooth";
deviceId?: string;
gattServiceUUID?: string;
},
opts: OpenTransportOptions,
): Promise<OpenTransportResult> {
if (!("bluetooth" in navigator)) {
throw new Error("Web Bluetooth not supported");
}
let device = opts.cachedBluetoothDevice;
if (!device) {
const bt = navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
};
if (bt.getDevices) {
const known = await bt.getDevices();
if (known.length > 0 && conn.deviceId) {
device = known.find((d) => d.id === conn.deviceId);
}
}
}
if (!device && opts.allowPrompt) {
device = await navigator.bluetooth.requestDevice({
acceptAllDevices: !conn.gattServiceUUID,
optionalServices: conn.gattServiceUUID ? [conn.gattServiceUUID] : undefined,
filters: conn.gattServiceUUID ? [{ services: [conn.gattServiceUUID] }] : undefined,
});
}
if (!device) {
throw new Error("Bluetooth device not available. Re-select the device.");
}
// Connect-failure semantics (transient retries, user-friendly messages)
// live inside the transport package — see BluetoothConnectError. This
// layer just surfaces whatever the transport produces.
const transport = await TransportWebBluetooth.createFromDevice(device);
return { transport, bluetoothDevice: device };
}
// Re-export so the Connections UI can `instanceof`-check for transient
// vs fatal failures without depending on the transport packages directly.
export { BluetoothConnectError, SerialConnectError };
async function openSerial(
conn: Connection & {
type: "serial";
usbVendorId?: number;
usbProductId?: number;
},
opts: OpenTransportOptions,
): Promise<OpenTransportResult> {
log.debug("openSerial: enter", {
hasCached: !!opts.cachedSerialPort,
allowPrompt: !!opts.allowPrompt,
vid: conn.usbVendorId,
pid: conn.usbProductId,
});
if (!("serial" in navigator)) {
throw new Error("Web Serial not supported");
}
const serial = (
navigator as Navigator & {
serial: {
getPorts: () => Promise<SerialPort[]>;
requestPort: (options: Record<string, unknown>) => Promise<SerialPort>;
};
}
).serial;
let port = opts.cachedSerialPort;
if (!port) {
const ports = await serial.getPorts();
log.debug("openSerial: getPorts", { count: ports.length });
if (ports && conn.usbVendorId && conn.usbProductId) {
port = ports.find((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => { usbVendorId?: number; usbProductId?: number };
}
).getInfo?.() ?? {};
return info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId;
});
}
}
if (!port && opts.allowPrompt) {
log.debug("openSerial: requesting port via picker");
port = await serial.requestPort({});
}
if (!port) {
log.warn("openSerial: no port resolved");
throw new Error("Serial port not available. Re-select the port.");
}
log.debug("openSerial: resolved port", {
readable: !!(port as SerialPort).readable,
writable: !!(port as SerialPort).writable,
});
// Port-state hygiene (force-close + retry open + busy detection) lives
// inside the transport package — see SerialConnectError. We just unwrap
// the Result and surface the user-facing message on failure.
const result = await TransportWebSerial.createFromPort(port);
if (Result.isError(result)) {
log.error("openSerial: createFromPort returned Err", {
kind: result.error.kind,
userMessage: result.error.userMessage,
});
throw result.error;
}
log.info("openSerial: transport ready");
return { transport: result.value, serialPort: port };
}
/**
* Probes a saved connection for reachability/permission without opening it.
* Used by refreshStatuses to update the saved-connection status badges.
*/
export async function probeConnection(
conn: Connection,
): Promise<"online" | "configured" | "disconnected" | "error"> {
switch (conn.type) {
case "http": {
const ok = await testHttpReachable(conn.url);
return ok ? "online" : "error";
}
case "bluetooth": {
if (!("bluetooth" in navigator)) return "disconnected";
try {
const known = await (
navigator.bluetooth as Navigator["bluetooth"] & {
getDevices?: () => Promise<BluetoothDevice[]>;
}
).getDevices?.();
const hasPermission = known?.some((d: BluetoothDevice) => d.id === conn.deviceId);
// Permission granted ≠ device configured. The card surfaces "online"
// (i.e. "available, click to connect") so the user explicitly opts
// into the configure handshake. "configured" is reserved for when
// the firmware has actually replied with config-complete.
return hasPermission ? "online" : "disconnected";
} catch {
return "disconnected";
}
}
case "serial": {
if (!("serial" in navigator)) return "disconnected";
try {
const ports: SerialPort[] = await (
navigator as Navigator & {
serial: { getPorts: () => Promise<SerialPort[]> };
}
).serial.getPorts();
const hasPermission = ports.some((p: SerialPort) => {
const info =
(
p as SerialPort & {
getInfo?: () => { usbVendorId?: number; usbProductId?: number };
}
).getInfo?.() ?? {};
return info.usbVendorId === conn.usbVendorId && info.usbProductId === conn.usbProductId;
});
return hasPermission ? "online" : "disconnected";
} catch {
return "disconnected";
}
}
}
}
/**
* Best-effort cleanup for a held-onto BT device or serial port. Safe on either.
*/
export function closeTransport(handle: BluetoothDevice | SerialPort | undefined): void {
if (!handle) return;
const bt = handle as BluetoothDevice;
if (bt.gatt?.connected) {
try {
bt.gatt.disconnect();
} catch {}
}
const port = handle as SerialPort & { close?: () => Promise<void> };
if (port.close) {
try {
port.close();
} catch {}
}
}

View File

@@ -1,5 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/core";
import { Protobuf } from "@meshtastic/sdk";
function createDefaultUser(num: number): Protobuf.Mesh.User {
const userIdHex = num.toString(16).toUpperCase().padStart(2, "0");

View File

@@ -1,6 +1,6 @@
import { MessageState, MessageType } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import type { Types } from "@meshtastic/core";
import type { Types } from "@meshtastic/sdk";
class PacketToMessageDTO {
channel: Types.ChannelNumber;

View File

@@ -0,0 +1,66 @@
import {
type Message as LegacyMessage,
MessageState as LegacyMessageState,
MessageType,
} from "@core/stores/messageStore";
import type { Message as SdkMessage } from "@meshtastic/sdk";
import { MessageState as SdkMessageState, type Types } from "@meshtastic/sdk";
import { useChat, useDirectChat } from "@meshtastic/sdk-react";
import { useMemo } from "react";
/**
* Adapter that surfaces SDK-managed chat history in the shape expected by
* the pre-SDK message components (`Message` from `messageStore`). Lets
* MessagesPage / ChannelChat / MessageItem keep their current props while
* reading from the OPFS-backed SQLite repository through the SDK chat
* slice. Removed once those components consume `Message` from the SDK
* directly.
*/
export interface UseChatAsLegacyMessagesBroadcast {
type: MessageType.Broadcast;
channelId: Types.ChannelNumber;
}
export interface UseChatAsLegacyMessagesDirect {
type: MessageType.Direct;
peer: number;
}
export type UseChatAsLegacyMessagesParams =
| UseChatAsLegacyMessagesBroadcast
| UseChatAsLegacyMessagesDirect;
export function useChatAsLegacyMessages(params: UseChatAsLegacyMessagesParams): LegacyMessage[] {
const broadcast = useChat(
params.type === MessageType.Broadcast ? params.channelId : (0 as Types.ChannelNumber),
);
const direct = useDirectChat(params.type === MessageType.Direct ? params.peer : 0);
const sdkMessages = params.type === MessageType.Broadcast ? broadcast.messages : direct.messages;
return useMemo(() => sdkMessages.map((m) => toLegacy(m, params)), [sdkMessages, params]);
}
function toLegacy(message: SdkMessage, params: UseChatAsLegacyMessagesParams): LegacyMessage {
return {
type: params.type,
channel: message.channel,
to: message.to,
from: message.from,
date: message.rxTime.getTime(),
messageId: message.id,
state: mapState(message.state),
message: message.text,
} as LegacyMessage;
}
function mapState(state: SdkMessageState): LegacyMessageState {
switch (state) {
case SdkMessageState.Ack:
return LegacyMessageState.Ack;
case SdkMessageState.Failed:
return LegacyMessageState.Failed;
case SdkMessageState.Pending:
default:
return LegacyMessageState.Waiting;
}
}

View File

@@ -0,0 +1,66 @@
import {
type Message as LegacyMessage,
MessageState as LegacyMessageState,
MessageType,
} from "@core/stores/messageStore";
import type { Message as SdkMessage } from "@meshtastic/sdk";
import { MessageState as SdkMessageState, type Types } from "@meshtastic/sdk";
import { useChat, useDirectChat } from "@meshtastic/sdk-react";
import { useMemo } from "react";
/**
* Adapter that surfaces SDK-managed chat history in the shape expected by
* the pre-SDK message components (`Message` from `messageStore`). Lets
* MessagesPage / ChannelChat / MessageItem keep their current props while
* reading from the OPFS-backed SQLite repository through the SDK chat
* slice. Removed once those components consume `Message` from the SDK
* directly.
*/
export interface UseChatAsLegacyMessagesBroadcast {
type: MessageType.Broadcast;
channelId: Types.ChannelNumber;
}
export interface UseChatAsLegacyMessagesDirect {
type: MessageType.Direct;
peer: number;
}
export type UseChatAsLegacyMessagesParams =
| UseChatAsLegacyMessagesBroadcast
| UseChatAsLegacyMessagesDirect;
export function useChatAsLegacyMessages(params: UseChatAsLegacyMessagesParams): LegacyMessage[] {
const broadcast = useChat(
params.type === MessageType.Broadcast ? params.channelId : (0 as Types.ChannelNumber),
);
const direct = useDirectChat(params.type === MessageType.Direct ? params.peer : 0);
const sdkMessages = params.type === MessageType.Broadcast ? broadcast.messages : direct.messages;
return useMemo(() => sdkMessages.map((m) => toLegacy(m, params)), [sdkMessages, params]);
}
function toLegacy(message: SdkMessage, params: UseChatAsLegacyMessagesParams): LegacyMessage {
return {
type: params.type,
channel: message.channel,
to: message.to,
from: message.from,
date: message.rxTime.getTime(),
messageId: message.id,
state: mapState(message.state),
message: message.text,
} as LegacyMessage;
}
function mapState(state: SdkMessageState): LegacyMessageState {
switch (state) {
case SdkMessageState.Ack:
return LegacyMessageState.Ack;
case SdkMessageState.Failed:
return LegacyMessageState.Failed;
case SdkMessageState.Pending:
default:
return LegacyMessageState.Waiting;
}
}

View File

@@ -6,7 +6,7 @@ interface UseCopyToClipboardProps {
export function useCopyToClipboard({ timeout = 2000 }: UseCopyToClipboardProps = {}) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {

View File

@@ -1,32 +1,17 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isFavorite: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateFavorite = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
const mockSendAdminMessage = vi.fn();
const mockSdkFavorite = vi.fn();
const mockSdkUnfavorite = vi.fn();
const mockByNum = vi.fn();
const { mockUseActiveClient } = vi.hoisted(() => ({
mockUseActiveClient: vi.fn(),
}));
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateFavorite: mockUpdateFavorite,
getNode: mockGetNode,
}),
useDevice: () => ({
sendAdminMessage: mockSendAdminMessage,
}),
vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
vi.mock("@core/hooks/useToast.ts", () => ({
@@ -38,17 +23,28 @@ vi.mock("@core/hooks/useToast.ts", () => ({
describe("useFavoriteNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
mockByNum.mockReturnValue({
num: 1234,
user: { longName: "Test Node" },
isFavorite: true,
});
mockUseActiveClient.mockReturnValue({
nodes: {
byNum: mockByNum,
favorite: mockSdkFavorite,
unfavorite: mockSdkUnfavorite,
},
});
});
it("calls updateFavorite and shows correct toast", () => {
it("calls SDK favorite and shows correct toast", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: true });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockSdkFavorite).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to favorites.",
});
@@ -61,18 +57,14 @@ describe("useFavoriteNode hook", () => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: false });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockSdkUnfavorite).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from favorites.",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
mockByNum.mockReturnValueOnce({ num: 5678, user: {} });
const { result } = renderHook(() => useFavoriteNode());
@@ -85,8 +77,8 @@ describe("useFavoriteNode hook", () => {
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
it("no-ops if node is not in the SDK store", () => {
mockByNum.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useFavoriteNode());
@@ -94,7 +86,8 @@ describe("useFavoriteNode hook", () => {
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
});
expect(mockUpdateFavorite).not.toHaveBeenCalled();
expect(mockSdkFavorite).not.toHaveBeenCalled();
expect(mockSdkUnfavorite).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice, useNodeDB } from "@core/stores";
import { Protobuf } from "@meshtastic/core";
import { useActiveClient } from "@meshtastic/sdk-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -10,44 +8,36 @@ interface FavoriteNodeOptions {
isFavorite: boolean;
}
/**
* Toggles the favorite flag on a node. Drives the SDK NodesClient which
* sends the matching admin message and flips the local flag on success.
*/
export function useFavoriteNode() {
const { sendAdminMessage } = useDevice();
const { getNode, updateFavorite } = useNodeDB();
const meshClient = useActiveClient();
const { t } = useTranslation();
const { toast } = useToast();
const updateFavoriteCB = useCallback(
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
const node = getNode(nodeNum);
if (!node) {
return;
}
if (!meshClient) return;
const node = meshClient.nodes.byNum(nodeNum);
if (!node) return;
sendAdminMessage(
create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: isFavorite ? "setFavoriteNode" : "removeFavoriteNode",
value: nodeNum,
},
}),
);
// TODO: Wait for response before changing the store
updateFavorite(nodeNum, isFavorite);
void (isFavorite ? meshClient.nodes.favorite(nodeNum) : meshClient.nodes.unfavorite(nodeNum));
toast({
title: t("toast.favoriteNode.title", {
action: isFavorite
? t("toast.favoriteNode.action.added")
: t("toast.favoriteNode.action.removed"),
nodeName: node?.user?.longName ?? t("node"),
nodeName: node.user?.longName ?? t("node"),
direction: isFavorite
? t("toast.favoriteNode.action.to")
: t("toast.favoriteNode.action.from"),
}),
});
},
[updateFavorite, sendAdminMessage, getNode, t, toast],
[meshClient, t, toast],
);
return { updateFavorite: updateFavoriteCB };

View File

@@ -1,32 +1,17 @@
import type { Protobuf } from "@meshtastic/core";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isIgnored: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateIgnore = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
const mockSendAdminMessage = vi.fn();
const mockSdkIgnore = vi.fn();
const mockSdkUnignore = vi.fn();
const mockByNum = vi.fn();
const { mockUseActiveClient } = vi.hoisted(() => ({
mockUseActiveClient: vi.fn(),
}));
vi.mock("@core/stores", () => ({
CurrentDeviceContext: {
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
updateIgnore: mockUpdateIgnore,
getNode: mockGetNode,
}),
useDevice: () => ({
sendAdminMessage: mockSendAdminMessage,
}),
vi.mock("@meshtastic/sdk-react", () => ({
useActiveClient: mockUseActiveClient,
}));
vi.mock("@core/hooks/useToast.ts", () => ({
@@ -38,17 +23,28 @@ vi.mock("@core/hooks/useToast.ts", () => ({
describe("useIgnoreNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
mockByNum.mockReturnValue({
num: 1234,
user: { longName: "Test Node" },
isIgnored: true,
});
mockUseActiveClient.mockReturnValue({
nodes: {
byNum: mockByNum,
ignore: mockSdkIgnore,
unignore: mockSdkUnignore,
},
});
});
it("calls updateIgnored and shows correct toast", () => {
it("calls SDK ignore and shows correct toast", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 1234, isIgnored: true });
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockSdkIgnore).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to ignore list",
});
@@ -58,24 +54,17 @@ describe("useIgnoreNode hook", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({
nodeNum: 1234,
isIgnored: false,
});
result.current.updateIgnored({ nodeNum: 1234, isIgnored: false });
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockSdkUnignore).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from ignore list",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
mockByNum.mockReturnValueOnce({ num: 5678, user: {} });
const { result } = renderHook(() => useIgnoreNode());
@@ -88,8 +77,8 @@ describe("useIgnoreNode hook", () => {
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
it("no-ops if node is not in the SDK store", () => {
mockByNum.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useIgnoreNode());
@@ -97,7 +86,8 @@ describe("useIgnoreNode hook", () => {
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
});
expect(mockUpdateIgnore).not.toHaveBeenCalled();
expect(mockSdkIgnore).not.toHaveBeenCalled();
expect(mockSdkUnignore).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More