Compare commits

...
2 Commits
Author SHA1 Message Date
kamilkrzywda 0571c7095e Telemetry stream: GET /api/telemetry + MCP get_telemetry (#16)
CI / build + unit (push) Successful in 21s
2026-09-24 23:56:11 +02:00
kamilkrzywda ba5e60daf0 Fix BigInt serialization in GET /api/device (500 on real hardware) (#14)
CI / build + unit (push) Successful in 18s
2026-09-24 23:14:32 +02:00
11 changed files with 334 additions and 8 deletions
+2 -2
View File
@@ -17,8 +17,8 @@
## API
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`).
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/device` (device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }` — DeviceMetadata as-is plus per-section Config/ModuleConfig messages; security publicKey/privateKey/adminKey and network wifiPsk redacted to `"REDACTED"`; in-memory, reset on reconnect), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/telemetry` (telemetry history newest first as `{ from, at /* epoch ms */, kind, data }[]`: live TELEMETRY_APP packets — device metrics and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name (deviceMetrics/environmentMetrics/airQualityMetrics/powerMetrics/localStats/healthMetrics/hostMetrics/trafficManagementStats), data that variant's payload with proto uint64 fields as decimal strings; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`).
3. **MCP endpoint** — `POST /mcp` (excluded from the `/api` prefix in main.ts, like Scalar at `/`): an in-process MCP server over the official SDK's Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`, fresh `McpServer` + transport per request). Tools map 1:1 to MeshService methods: `mesh_status`, `send_message(text, to?)` (same resolution as `POST /api/send`; errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), `get_messages`, `get_telemetry` (same bounded recent window as `GET /api/telemetry`), `device_info` (same redaction as `GET /api/device`). Results are JSON in a text content block; sends ride channel index 3 like everything else. Note: the SDK's root entry is broken upstream (#2273) — import via subpaths (`@modelcontextprotocol/sdk/server/mcp.js` etc.).
## Other
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## v0.8.0 — 2026-09-24 21:40
- New `GET /api/telemetry` endpoint (and matching MCP tool `get_telemetry`, now eight tools): the telemetry stream captured from live `TELEMETRY_APP` packets (portnum 67) — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. Records are `{ from, at /* epoch ms */, kind, data }[]` newest first: `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` is that variant's payload. In-memory only, last 100 (same bounded pattern as message history), reset on every bridge reconnect.
- `MeshService.telemetry()` captures the new portnum in the existing packet handler: the `Data.payload` is decoded as a `meshtastic.Telemetry`, and packets without a variant payload are ignored. The variant payload goes through the same module-scope `jsonSafe` converter added in v0.7.1 — telemetry variants carry proto `uint64` fields (e.g. host-metrics byte counters) that decode to native bigint and would 500 Express' `res.json` otherwise, so they come back as decimal strings; unset optional fields are omitted from `data`.
- Verified against the installed `@meshtastic/protobufs` 2.8.0: `PortNum.TELEMETRY_APP = 67`; `Telemetry` is a `fixed32 time` plus oneof variant with exactly those eight cases; `DeviceMetrics` fields are batteryLevel/voltage/channelUtilization/airUtilTx/uptimeSeconds and `EnvironmentMetrics` the full sensor set (temperature, relativeHumidity, barometricPressure, gasResistance, voltage, current, iaq, distance, lux variants, wind/rainfall/soil/lightning, adc channels).
- Swagger: `GET /api/telemetry` documented with the kind values and an example carrying one record per kind. README + AGENTS.md endpoint/tool lists updated.
- Tests: fake-bridge gains a shared `telemetryPacketFrame` builder; new telemetry specs (device-metrics + environment variants → correct from/kind/data, cap at 100 dropping the oldest, reset on reconnect); MCP spec updated to eight tools incl. a `get_telemetry` call check.
## v0.7.1 — 2026-09-24 21:10
- Fix `GET /api/device` and MCP `device_info` crashing with `TypeError: Do not know how to serialize a BigInt` on live hardware (fw 2.7.15): protobuf-es decodes proto `uint64` fields as native JS `bigint`, which Express' `res.json`/`JSON.stringify` reject, and `MeshService.deviceInfo()` passed raw decoded proto objects through unconverted. Known affected fields: `Config.PowerConfig.powermonEnables` (in `localConfig.power`) and host-metrics byte counters (`freememBytes`/`diskfree*`).
- Fix at the single choke point: one small recursive converter (`jsonSafe`, bigint → decimal string, plain objects/arrays/primitives pass through) applied in `deviceInfo()` to everything that comes from raw decoded proto messages — DeviceMetadata plus every localConfig and moduleConfig section. No per-field patching; any uint64 field in those messages is covered generically. Values that were bigint now serialize as strings (e.g. `"powermonEnables": "123"`); no other endpoint/shape changes, redaction logic untouched.
- Tests: new device-info spec feeds a power config section frame with `powermonEnables` set to a real bigint through the fake bridge and asserts the value comes back as its string form (`"123"`) and that `JSON.stringify(deviceInfo())` succeeds — the old fixtures never set a uint64 field, which is why this slipped past the suite.
## v0.7.0 — 2026-09-24 20:21
- New `GET /api/device` endpoint (and matching MCP tool `device_info`, now seven tools): device info captured during the initial sync — this device's node number, firmware version and metadata (`meshtastic.DeviceMetadata` as-is; it carries no key material), plus per-section config and module-config messages keyed by section name. In-memory only, reset on every bridge reconnect like channels/nodes.
+3
View File
@@ -30,6 +30,7 @@ All API endpoints live under the `/api` prefix:
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
- `GET /api/device` — device info captured during the initial sync: `{ myNodeNum, firmwareVersion?, metadata?, localConfig: {<section>: ...}, moduleConfig: {<module>: ...} }`. Key material is never exposed (security publicKey/privateKey/adminKey and network wifiPsk are replaced with `"REDACTED"`); in-memory only, reset on every bridge reconnect
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnect
- `GET /api/telemetry` — telemetry history, newest first: live `TELEMETRY_APP` packets from any node as `{ from, at /* epoch ms */, kind, data }[]`. `kind` is the `meshtastic.Telemetry` oneof case name (`deviceMetrics`, `environmentMetrics`, `airQualityMetrics`, `powerMetrics`, `localStats`, `healthMetrics`, `hostMetrics` or `trafficManagementStats`) and `data` carries that variant's payload (proto uint64 fields as decimal strings; unset optional fields omitted). In-memory only, last 100, reset on every bridge reconnect
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? }, snr?, hopsAway?, viaMqtt? }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), `position` only when both coordinates are known, and snr/hopsAway/viaMqtt only when carried by the NodeInfo
- `GET /api/nodes/:id` — one node looked up by exact node number (numbers or numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match; 404 when nothing or more than one node matches
@@ -43,6 +44,7 @@ curl -X POST http://localhost:3000/api/send -H 'content-type: application/json'
curl http://localhost:3000/api/channels
curl http://localhost:3000/api/device
curl http://localhost:3000/api/messages
curl http://localhost:3000/api/telemetry
curl http://localhost:3000/api/nodes
curl http://localhost:3000/api/nodes/kamil
```
@@ -68,6 +70,7 @@ endpoints — no new logic, same channel pinning:
- `list_nodes` — known nodes as lean summaries
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
- `get_messages` — message history, newest first (last 100, tx records carry delivery status)
- `get_telemetry` — telemetry history, newest first (last 100): device metrics and environment sensor readings from any node; `kind` is the Telemetry oneof case name
- `device_info` — device info from the initial sync: node number, firmware version, metadata and per-section config/module-config (key material redacted as `"REDACTED"`)
Point any MCP client at it (Streamable HTTP URL):
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.7.0",
"version": "0.8.0",
"description": "",
"author": "",
"private": true,
+10
View File
@@ -14,6 +14,8 @@ export const PORT_TEXT_MESSAGE_APP = 1;
export const PORT_POSITION_APP = 3;
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
export const PORT_ROUTING_APP = 5;
// verified against installed @meshtastic/protobufs Portnums.PortNum.TELEMETRY_APP
export const PORT_TELEMETRY_APP = 67;
const START1 = 0x94;
const START2 = 0xc3;
@@ -81,6 +83,14 @@ export async function decodeRouting(
return fromBinary(P.Mesh.RoutingSchema, bytes);
}
/** TELEMETRY_APP packets carry a serialized meshtastic.Telemetry in Data.payload. */
export async function decodeTelemetry(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Telemetry.Telemetry> {
const P = await loadProto();
return fromBinary(P.Telemetry.TelemetrySchema, bytes);
}
/** Human-readable name for a Routing.Error code (from the installed @meshtastic/protobufs enum), `reason <n>` fallback. */
export async function describeRoutingError(code: number): Promise<string> {
const P = await loadProto();
+27
View File
@@ -121,6 +121,33 @@ export function nodeInfoFrame(
);
}
/** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */
export function telemetryPacketFrame(
from: number,
variant: P.Telemetry.Telemetry['variant'],
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TELEMETRY_APP,
payload: toBinary(
P.Telemetry.TelemetrySchema,
create(P.Telemetry.TelemetrySchema, { variant }),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
id: 1,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
export const KAMIL_NUM = 123456789;
export function kamilNodeInfo(): NodeInfoFields {
+22 -1
View File
@@ -8,6 +8,7 @@ import {
kamilNodeInfo,
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
waitFor,
} from './fake-bridge.js';
import { createMeshMcpServer } from './mcp.controller.js';
@@ -64,7 +65,7 @@ async function connectMcp(svc: MeshService): Promise<{
}
describe('MCP server tools', () => {
it('lists all seven tools with descriptions and input schemas', async () => {
it('lists all eight tools with descriptions and input schemas', async () => {
const env = await connectedService();
try {
const mcp = await connectMcp(env.svc);
@@ -74,6 +75,7 @@ describe('MCP server tools', () => {
'device_info',
'get_messages',
'get_node',
'get_telemetry',
'list_channels',
'list_nodes',
'mesh_status',
@@ -263,6 +265,25 @@ describe('MCP server tools', () => {
),
) as { dir: string; to?: number }[];
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
env.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87 },
}),
);
await waitFor(() => env.svc.telemetry().length === 1);
const tel = JSON.parse(
toolText(
await mcp.client.callTool({ name: 'get_telemetry', arguments: {} }),
),
) as { from: number; kind: string; data: object }[];
expect(tel).toHaveLength(1);
expect(tel[0]).toMatchObject({
from: KAMIL_NUM,
kind: 'deviceMetrics',
});
expect(tel[0].data).toEqual({ batteryLevel: 87 });
} finally {
await mcp.close();
}
+8
View File
@@ -77,6 +77,14 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
async () => jsonResult(mesh.messages()),
);
server.registerTool(
'get_telemetry',
{
description: `Telemetry history, newest first (bounded recent window, last 100): TELEMETRY_APP packets from any node — device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them. kind is the meshtastic.Telemetry oneof case name; data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
},
async () => jsonResult(mesh.telemetry()),
);
server.registerTool(
'device_info',
{
+34
View File
@@ -183,6 +183,40 @@ export class MeshController {
return this.mesh.messages();
}
@Get('telemetry')
@ApiOperation({
summary: 'Telemetry history (last 100)',
description:
"TELEMETRY_APP packets from any node, newest first. Device metrics (uptime/battery/voltage/channel+air utilization) and environment sensor readings where nodes have them; kind is the meshtastic.Telemetry oneof case name — deviceMetrics, environmentMetrics, airQualityMetrics, powerMetrics, localStats, healthMetrics, hostMetrics or trafficManagementStats — and data carries that variant's payload (proto uint64 fields as decimal strings). In-memory only (last 100), reset on every bridge reconnect.",
})
@ApiResponse({
status: 200,
description: 'Telemetry records newest first',
example: [
{
from: 987654321,
at: 1758000123456,
kind: 'deviceMetrics',
data: {
batteryLevel: 87,
voltage: 4.1,
channelUtilization: 12.5,
airUtilTx: 3.2,
uptimeSeconds: 86400,
},
},
{
from: 123456789,
at: 1758000000000,
kind: 'environmentMetrics',
data: { temperature: 21.5, relativeHumidity: 48 },
},
],
})
telemetry() {
return this.mesh.telemetry();
}
@Get('nodes')
@ApiOperation({
summary: 'Known mesh nodes (lean summaries)',
+142
View File
@@ -10,6 +10,7 @@ import {
moduleConfigSectionFrame,
nodeInfoFrame,
startBridge,
telemetryPacketFrame,
waitFor,
} from './fake-bridge.js';
import { MeshService } from './mesh.service.js';
@@ -560,6 +561,121 @@ describe('MeshService message history', () => {
});
});
describe('MeshService telemetry', () => {
it('captures device metrics and environment variants with from/kind/data', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
expect(svc.telemetry()).toEqual([]); // nothing before the first packet
bridge.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, {
case: 'deviceMetrics',
value: { batteryLevel: 87, voltage: 4.1, uptimeSeconds: 3600 },
}),
);
bridge.sockets[0].write(
telemetryPacketFrame(987654321, {
case: 'environmentMetrics',
value: { temperature: 21.5, relativeHumidity: 48 },
}),
);
await waitFor(() => svc.telemetry().length === 2);
expect(svc.telemetry()).toEqual([
{
from: 987654321, // newest first
at: expect.any(Number),
kind: 'environmentMetrics',
// the deprecated repeated one_wire_temperature field decodes as an empty list even when unset
data: {
temperature: 21.5,
relativeHumidity: 48,
oneWireTemperature: [],
},
},
{
from: KAMIL_NUM,
at: expect.any(Number),
kind: 'deviceMetrics',
// proto float is IEEE-754 single precision — 4.1 widens to this exact double
data: {
batteryLevel: 87,
voltage: 4.099999904632568,
uptimeSeconds: 3600,
},
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('caps telemetry at 100 records, dropping the oldest', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
for (let i = 0; i < 105; i++)
bridge.sockets[0].write(
telemetryPacketFrame(42, {
case: 'deviceMetrics',
value: { batteryLevel: i },
}),
);
await waitFor(() => svc.telemetry().length === 100, 10000);
expect(svc.telemetry()[0]?.data).toEqual({ batteryLevel: 104 }); // newest first
expect(svc.telemetry()[99]?.data).toEqual({ batteryLevel: 5 }); // i=0..4 dropped
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('resets telemetry on reconnect (no stale data)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_RECONNECT_MS = '50';
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
bridge.sockets[0].write(
telemetryPacketFrame(KAMIL_NUM, { case: 'deviceMetrics', value: {} }),
);
await waitFor(() => svc.telemetry().length === 1);
// drop the connection; bot reconnects and must start with an empty history
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
await waitFor(() => svc.telemetry().length === 0);
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService resolveDest', () => {
it('resolves broadcast sentinels, numbers and names to node numbers', async () => {
const bridge = await startBridge();
@@ -718,6 +834,32 @@ describe('MeshService device info', () => {
}
});
it('serializes proto uint64 fields as strings (JSON-safe)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
// powermonEnables is a proto uint64 — protobuf-es decodes it as native bigint
bridge.sockets[0].write(
configSectionFrame('power', { powermonEnables: 123n }),
);
await waitFor(() => svc.deviceInfo().localConfig.power !== undefined);
const info = svc.deviceInfo();
expect(info.localConfig.power?.powermonEnables).toBe('123'); // bigint -> decimal string
expect(() => JSON.stringify(info)).not.toThrow(); // no "Do not know how to serialize a BigInt"
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('summarizes NodeInfo snr/hopsAway/viaMqtt when present', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
+71 -4
View File
@@ -11,12 +11,14 @@ import {
BROADCAST_ADDR,
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TELEMETRY_APP,
PORT_TEXT_MESSAGE_APP,
buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio,
decodePosition,
decodeRouting,
decodeTelemetry,
describeRoutingError,
extractFrames,
frame,
@@ -41,6 +43,18 @@ export interface MessageRecord {
ackError?: string;
}
/** One entry in the telemetry history: a TELEMETRY_APP packet from some node. */
export interface TelemetryRecord {
/** sender node num */
from: number;
/** epoch ms */
at: number;
/** meshtastic.Telemetry oneof case name (e.g. "deviceMetrics", "environmentMetrics") */
kind: string;
/** the variant payload, JSON-safe (proto uint64 fields as decimal strings); unset optional fields omitted */
data: object;
}
// bounded history: keep the newest N records, in-memory only
const HISTORY_LIMIT = 100;
@@ -76,6 +90,21 @@ export interface DeviceInfo {
moduleConfig: Record<string, Record<string, unknown>>;
}
// protobuf-es decodes proto uint64 fields as native bigint, which JSON.stringify rejects;
// recursively convert so deviceInfo()/telemetry() output is always JSON-safe (bigint -> decimal string).
// $-prefixed keys are protobuf-es internals ($typeName/$unknown), not device data — dropped.
function jsonSafe<T>(value: T): T {
if (typeof value === 'bigint') return String(value) as T;
if (Array.isArray(value)) return value.map(jsonSafe) as T;
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value))
if (!k.startsWith('$')) out[k] = jsonSafe(v);
return out as T;
}
return value; // primitives + undefined pass through
}
// key material must never leave the process — replaced with a marker so callers can see the field exists
const REDACTED = 'REDACTED';
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
@@ -124,6 +153,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
private history: MessageRecord[] = [];
private pendingTx = new Map<number, MessageRecord>();
// telemetry history (newest kept, capped at HISTORY_LIMIT)
private telemetryHistory: TelemetryRecord[] = [];
private reconnectTimer: NodeJS.Timeout | null = null;
private shuttingDown = false;
@@ -170,7 +201,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
* metadata (DeviceMetadata as-is — it carries no key material), plus per-section config and
* module-config messages keyed by section name. In-memory only; empty until the sync completes,
* reset on every bridge reconnect. Key material is redacted: security publicKey/privateKey/adminKey
* and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP.
* and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP. Proto uint64 fields
* (e.g. power.powermonEnables) come back as decimal strings so the output always JSON-serializes.
*/
deviceInfo(): DeviceInfo {
const out: DeviceInfo = {
@@ -180,12 +212,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
};
if (this.metadata) {
out.firmwareVersion = this.metadata.firmwareVersion;
out.metadata = this.metadata;
out.metadata = jsonSafe(this.metadata);
}
for (const [name, value] of this.configSections)
out.localConfig[name] = redactSection(name, value);
out.localConfig[name] = jsonSafe(redactSection(name, value));
for (const [name, value] of this.moduleConfigSections)
out.moduleConfig[name] = value;
out.moduleConfig[name] = jsonSafe(value);
return out;
}
@@ -271,6 +303,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
return [...this.history].reverse();
}
/**
* Telemetry records, newest first: TELEMETRY_APP packets from any node — device metrics
* (battery/voltage/uptime/channel+air utilization) and environment sensor readings where nodes have them.
* `kind` is the meshtastic.Telemetry oneof case name; `data` is that variant's payload with proto uint64
* fields as decimal strings, unset optional fields omitted. In-memory only, last 100, reset on reconnect.
*/
telemetry(): TelemetryRecord[] {
return [...this.telemetryHistory].reverse();
}
/** Send `text` to node `to` on the pinned test channel; returns the packet id. Throws ServiceUnavailable when the bridge is down. */
async sendText(to: number, text: string): Promise<number> {
if (!this.connected || !this.socket)
@@ -297,6 +339,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
if (this.history.length > HISTORY_LIMIT) this.history.shift();
}
private pushTelemetry(rec: TelemetryRecord): void {
this.telemetryHistory.push(rec);
// ponytail: shift on a ≤100 array is fine; true ring buffer only if this ever gets hot
if (this.telemetryHistory.length > HISTORY_LIMIT)
this.telemetryHistory.shift();
}
private summarize(n: NodeEntry, names: Map<number, string>): NodeSummary {
const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null };
if (n.user?.longName) s.longName = n.user.longName;
@@ -333,6 +382,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.configSections.clear();
this.moduleConfigSections.clear();
this.history = [];
this.telemetryHistory = [];
this.pendingTx.clear();
this.logger.log(`bridge connected ${this.host}:${this.port}`);
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
@@ -467,6 +517,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.pendingTx.delete(decoded.requestId);
}
}
} else if (decoded && decoded.portnum === PORT_TELEMETRY_APP) {
// live telemetry: the Telemetry is serialized in Data.payload
let tel: Proto.Telemetry.Telemetry | undefined;
try {
tel = await decodeTelemetry(decoded.payload);
} catch {
tel = undefined; // malformed payload — skip
}
const variant = tel?.variant;
if (variant?.case) {
this.pushTelemetry({
from: pkt.from,
at: Date.now(),
kind: variant.case,
data: jsonSafe(variant.value),
});
}
}
break;
}