Ben Meadors ad692eef74 test(e2e): real-device Playwright messaging suite (#1121)
* fix(connections): connect to the just-added connection via the live store

addConnectionAndConnect() adds a connection and then connects to it in the
same tick, but connect() resolved the id against the memoized `connections`
closure, which is stale until the hook re-renders. The just-added id was
therefore reported as an unknown connection id and Save silently never
connected any HTTP/Serial/Bluetooth device. Read savedConnections from
useDeviceStore.getState() so the lookup always sees the live store.

* test(e2e): real-device Playwright messaging suite

Drives the actual web app in Chromium against real meshtasticd firmware over
the HTTP phone API and verifies text messaging in both directions across a
two-node mesh. Nodes mesh over the firmware's built-in UDP multicast
(224.0.0.69) with no MQTT/relay; distinct node numbers, real encryption.

- Default backend: two Docker meshtasticd sim nodes (daily-debian). The same
  specs run against physical hardware via E2E_DEVICE_MODE=hardware.
- An off-browser Python meshtastic peer (e2e/peer/peer.py) drives/asserts the
  non-browser node over the TCP phone API, mirroring firmware mcp-server tests.
- Coverage: connect over HTTPS, mesh->web receive, web->mesh send. Direct
  messages are fixme'd (see below). CI workflow runs it on Linux.

Bugs surfaced by the suite:
- Fixed (prior commit): connect-on-save never connected (stale-closure id
  lookup in useConnections).
- Not fixed: apps/web/src/core/subscriptions.ts throws 'ReferenceError:
  nodeDB is not defined' on every device-metrics telemetry packet (the #1050
  migration removed that store); caught per-packet, so messaging still works.
- Not fixed: direct messages are blocked by a PKI 'Keys Mismatch' (the SDK's
  stored peer public key != the key presented during NodeInfo exchange), seen
  even with fresh sim nodes.

* test(e2e): address Copilot review feedback

- waitForTcp(): destroy the probe socket on the error path so repeated
  connection failures don't accumulate sockets/FDs across the retry loop.
- Don't remove the mesh containers in Playwright globalTeardown in CI — it
  raced the workflow's failure log capture. Teardown is now gated on
  E2E_DOCKER_DOWN only; CI dumps device logs on failure and tears the mesh
  down in a final always() workflow step.

* fix(sdk): fold device-metrics telemetry into nodes

apps/web/src/core/subscriptions.ts called nodeDB.addDeviceMetrics() on every
device-metrics telemetry packet, but the #1050 migration removed that store —
so it threw 'ReferenceError: nodeDB is not defined' on each telemetry packet
(caught per-packet by the SDK's HandleFromRadio, so messaging still worked but
the error spammed the console).

Route device metrics into the SDK NodesClient via onTelemetryPacket instead —
mirroring the existing position handler; the Node domain already carries a
deviceMetrics field — and drop the dead app-side handler. Adds a NodesClient
test covering the fold.

* docs(e2e): accurate DM root cause + bug status

The direct-message fixme is a simulator limitation, not a web-app bug: the
keyless meshtasticd sim nodes NAK a DM with NO_CHANNEL (routing error 6) — no
Curve25519 keypair is provisioned/shared, and current firmware can't deliver a
direct message without a per-node key / decryptable channel. The app surfaces
this correctly (key-refresh dialog). Re-enable against hardware or once the sim
provisions keys.

Also: mark the nodeDB telemetry bug fixed and note the CI teardown change.

* docs(e2e): precise DM root cause (firmware/sim PKI)

Followed up on the suggestion to provision keys in config.security: the keys
ARE settable and persist (verified via admin), but on the native meshtasticd
sim they don't sync to the node's owner / NodeInfo key — owner.public_key stays
empty and the node keeps its MAC-derived num — so the two nodes never exchange
keys. Combined with the firmware refusing non-PKI DMs ('Unknown public key for
destination ... refusing to send legacy DM'), the DM is NAK'd with NO_CHANNEL.
A firmware/sim limitation; DMs work on real hardware. Spec stays fixme.

* docs(e2e): definitive DM root cause (SimRadio PKC payload limit)

Per the steer to research the firmware: PKI keygen is gated on a set LoRa
region (NodeDB.cpp:3051) and the sim boots region-UNSET — setting lora.region
via admin DOES make the nodes generate and exchange keys (verified both ways).
But a PKI-encrypted DM still can't traverse the SimRadio: the PKC overhead
exceeds its payload limit ('Payload size larger than compressed message allows!
Send empty payload'), so the packet is truncated and the receiver NAKs
NO_CHANNEL ('No suitable channel found for decoding, hash 0x0'). The firmware
skips PKC under --sim (Router.cpp:730) for exactly this reason, but --sim also
disables the config-file loading the web app needs, so they're mutually
exclusive. DMs work on real hardware; spec stays fixme with this detail.

---------

Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
2026-06-20 11:19:50 -04:00
2026-06-16 20:30:08 -04:00
2026-06-16 20:30:08 -04:00
2026-06-16 20:30:08 -04:00
2026-06-16 20:30:08 -04:00
2025-07-10 12:28:23 -04:00
2026-06-16 20:30:08 -04:00
2026-06-16 20:30:08 -04:00

Meshtastic Web Monorepo

CI CI CLA assistant Fiscal Contributors Vercel

Overview

This monorepo consolidates the official Meshtastic web 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/.

Packages

All projects live under packages/.

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.
packages/ui Shared Radix + Tailwind component library.
packages/protobufs Generated TypeScript stubs from 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 publishable packages ship to both JSR and NPM.

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; exceptions are reserved for programmer errors and truly exceptional conditions.

Getting Started

Prerequisites

You need pnpm installed. If you plan to regenerate protobufs, also install the Buf CLI.

Setup

git clone https://github.com/meshtastic/web.git
cd web
pnpm install

Run the web client

pnpm --filter @meshtastic/web dev

Build everything

pnpm -r build

Run tests

pnpm -r test

Lint + format

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:

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

Project Repobeats
Meshtastic Web Alt

Feedback

If you encounter any issues, please report them in our issues tracker. Your feedback helps improve the stability of future releases.

Star history

Star History Chart

Contributors

License

GPL-3.0-only. See LICENSE.

Description
No description provided
Readme GPL-3.0 25 MiB
Languages
TypeScript 98.5%
CSS 0.6%
JavaScript 0.5%
Python 0.3%