Files
meshtastic-web/apps/web/src/core/subscriptions.ts
T
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

116 lines
4.1 KiB
TypeScript

import { useNewNodeNum } from "@core/hooks/useNewNodeNum";
import { type Device } from "@core/stores";
import { type MeshDevice, Protobuf } from "@meshtastic/sdk";
/**
* Wires up the legacy MeshDevice event stream into the web's Zustand stores.
*
* Note: the SDK now owns chat persistence (via SqlocalMessageRepository) and
* the entire NodesClient surface — node info, user, position, lastHeard /
* snr, favourite / ignored flags, and PKI-error tracking. This handler no
* longer mirrors any of that into the legacy stores; what remains is
* device-store-only state (waypoints, traceroutes, neighbour info, dialog
* open triggers, unread counts).
*/
export const subscribeAll = (device: Device, connection: MeshDevice) => {
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
device.addMetadata(metadataPacket.from, metadataPacket.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
switch (routingPacket.data.variant.case) {
case "errorReason": {
if (
routingPacket.data.variant.value === Protobuf.Mesh.Routing_Error.NONE
) {
return;
}
console.info(`Routing Error: ${routingPacket.data.variant.value}`);
break;
}
case "routeReply": {
console.info(`Route Reply: ${routingPacket.data.variant.value}`);
break;
}
case "routeRequest": {
console.info(`Route Request: ${routingPacket.data.variant.value}`);
break;
}
}
});
connection.events.onDeviceStatus.subscribe((status) => {
device.setStatus(status);
});
connection.events.onWaypointPacket.subscribe((waypoint) => {
const { data, channel, from, rxTime } = waypoint;
device.addWaypoint(data, channel, from, rxTime);
});
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
useNewNodeNum(device.id, nodeInfo);
});
// onUserPacket / onPositionPacket / onNodeInfoPacket and device-metrics
// telemetry (battery / channel utilisation / voltage) are folded into nodes by
// the SDK NodesClient (see packages/sdk/src/features/nodes/NodesClient.ts).
connection.events.onChannelPacket.subscribe((channel) => {
device.addChannel(channel);
});
connection.events.onConfigPacket.subscribe((config) => {
device.setConfig(config);
});
connection.events.onModuleConfigPacket.subscribe((moduleConfig) => {
device.setModuleConfig(moduleConfig);
});
// Inbound message handling (persistence, unread counts) lives entirely on
// the SDK ChatClient now — see ChatClient + chat.unread.
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
device.addTraceRoute({
...traceRoutePacket,
});
});
connection.events.onPendingSettingsChange.subscribe((state) => {
device.setPendingSettingsChanges(state);
});
// onMeshPacket → lastHeard / snr per-node updates are handled by the SDK
// NodesClient.
connection.events.onClientNotificationPacket.subscribe(
(clientNotificationPacket) => {
device.addClientNotification(clientNotificationPacket);
device.setDialogOpen("clientNotification", true);
},
);
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
// Per-node error tracking lives on the SDK NodesClient
// (client.nodes.errors); the dialog open trigger stays here so the
// legacy device-store-driven dialog manager keeps working.
device.setDialogOpen("refreshKeys", true);
break;
default:
break;
}
}
});
};