Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4beaa377c4 |
@@ -17,8 +17,8 @@
|
|||||||
## API
|
## 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).
|
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/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? } }[]`, 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`).
|
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`. 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.).
|
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.).
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `MeshService.deviceInfo()` captures the new FromRadio cases during sync: `metadata` (DeviceMetadata), `config` (one `meshtastic.Config` per section — oneof `payloadVariant` cases device/position/power/network/display/lora/bluetooth/security/sessionkey/deviceUi) and `moduleConfig` (one ModuleConfig per module). Verified against the installed `@meshtastic/protobufs` 2.8.0: FromRadio carries `meshtastic.Config`, not `LocalConfig` (that type lives in a separate localonly namespace and is not part of FromRadio).
|
||||||
|
- **Mandatory redaction (trust boundary):** key material never leaves the process — security section `publicKey`/`privateKey`/`adminKey` and network section `wifiPsk` are replaced with `"REDACTED"` in all output.
|
||||||
|
- Node summaries (`GET /api/nodes`, `GET /api/nodes/:id`, MCP `list_nodes`/`get_node`) gain optional fields from the NodeInfo: `snr` (dB), `hopsAway`, `viaMqtt` — present only when the device reported them. Note: `meshtastic.NodeInfo` in 2.8.0 has no firmware version field, so per-node firmwareVersion is not captured (device-level firmware version comes from DeviceMetadata via `/api/device`).
|
||||||
|
- Swagger: `GET /api/device` documented with the redaction note and an example; nodes endpoint example/description extended for the new optional fields. README + AGENTS.md endpoint lists updated.
|
||||||
|
- Tests: fake-bridge gains `metadataFrame`, `configSectionFrame`, `moduleConfigSectionFrame`; new device-info specs (metadata → firmwareVersion, config/module-config sections captured with security key material absent from the JSON output), NodeInfo snr/hopsAway/viaMqtt in summaries; MCP spec updated to seven tools incl. a `device_info` call check.
|
||||||
|
|
||||||
## v0.6.0 — 2026-09-24 18:28
|
## v0.6.0 — 2026-09-24 18:28
|
||||||
|
|
||||||
- New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block.
|
- New MCP (Model Context Protocol) server endpoint at `POST /mcp` (outside the `/api` prefix, like Scalar at `/`), served in-process via the official `@modelcontextprotocol/sdk` Streamable HTTP transport in stateless mode (`sessionIdGenerator: undefined`; a fresh `McpServer` + transport per request). Six tools map 1:1 to existing `MeshService` methods with no new logic: `mesh_status`, `send_message(text, to?)` (same target resolution as `POST /api/send` — omitted/""/"!b"/"broadcast" → broadcast, numbers pass through, names via the node DB; resolution errors surface as tool errors with the same messages), `list_channels`, `list_nodes`, `get_node(id)` (tool error when missing/ambiguous), and `get_messages`. Tool results are JSON in a text content block.
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ All API endpoints live under the `/api` prefix:
|
|||||||
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
- `GET /api/status` — `{ connected, myNodeNum, host, port }`
|
||||||
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
- `POST /api/send` — body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
||||||
- `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/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/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/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, 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), and `position` only when both coordinates are known
|
- `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
|
- `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
|
||||||
|
|
||||||
Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
Interactive API docs (Scalar) are served at `/`, with the OpenAPI JSON at
|
||||||
@@ -40,6 +41,7 @@ curl http://localhost:3000/api/status
|
|||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"text":"hi"}'
|
||||||
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
curl -X POST http://localhost:3000/api/send -H 'content-type: application/json' -d '{"to":123456789,"text":"hello"}'
|
||||||
curl http://localhost:3000/api/channels
|
curl http://localhost:3000/api/channels
|
||||||
|
curl http://localhost:3000/api/device
|
||||||
curl http://localhost:3000/api/messages
|
curl http://localhost:3000/api/messages
|
||||||
curl http://localhost:3000/api/nodes
|
curl http://localhost:3000/api/nodes
|
||||||
curl http://localhost:3000/api/nodes/kamil
|
curl http://localhost:3000/api/nodes/kamil
|
||||||
@@ -66,6 +68,7 @@ endpoints — no new logic, same channel pinning:
|
|||||||
- `list_nodes` — known nodes as lean summaries
|
- `list_nodes` — known nodes as lean summaries
|
||||||
- `get_node(id)` — one node by number or name; tool error when missing/ambiguous
|
- `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_messages` — message history, newest first (last 100, tx records carry delivery status)
|
||||||
|
- `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):
|
Point any MCP client at it (Streamable HTTP URL):
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -46,6 +46,64 @@ export interface NodeInfoFields {
|
|||||||
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
|
position?: { latitudeI?: number; longitudeI?: number; altitude?: number };
|
||||||
lastHeard?: number;
|
lastHeard?: number;
|
||||||
deviceMetrics?: { batteryLevel?: number };
|
deviceMetrics?: { batteryLevel?: number };
|
||||||
|
snr?: number;
|
||||||
|
viaMqtt?: boolean;
|
||||||
|
hopsAway?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// init shape for create(): all fields optional (protobuf-es' MessageInit is not exported)
|
||||||
|
type Init<T> = {
|
||||||
|
[P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DeviceMetadata frame as streamed once during the initial sync. */
|
||||||
|
export function metadataFrame(md: Init<P.Mesh.DeviceMetadata>): Uint8Array {
|
||||||
|
const fr = create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'metadata', value: md },
|
||||||
|
});
|
||||||
|
return frame(toBinary(P.Mesh.FromRadioSchema, fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config/ModuleConfig section frames as streamed one-per-section during the initial sync.
|
||||||
|
// (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh)
|
||||||
|
export function configSectionFrame(
|
||||||
|
sectionCase: P.Config.Config['payloadVariant']['case'],
|
||||||
|
value: unknown,
|
||||||
|
): Uint8Array {
|
||||||
|
const cfg = create(P.Config.ConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: sectionCase,
|
||||||
|
value,
|
||||||
|
} as P.Config.Config['payloadVariant'],
|
||||||
|
});
|
||||||
|
return frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'config', value: cfg },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleConfigSectionFrame(
|
||||||
|
sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'],
|
||||||
|
value: unknown,
|
||||||
|
): Uint8Array {
|
||||||
|
const mc = create(P.ModuleConfig.ModuleConfigSchema, {
|
||||||
|
payloadVariant: {
|
||||||
|
case: sectionCase,
|
||||||
|
value,
|
||||||
|
} as P.ModuleConfig.ModuleConfig['payloadVariant'],
|
||||||
|
});
|
||||||
|
return frame(
|
||||||
|
toBinary(
|
||||||
|
P.Mesh.FromRadioSchema,
|
||||||
|
create(P.Mesh.FromRadioSchema, {
|
||||||
|
payloadVariant: { case: 'moduleConfig', value: mc },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeInfoFrame(
|
export function nodeInfoFrame(
|
||||||
|
|||||||
@@ -64,13 +64,14 @@ async function connectMcp(svc: MeshService): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MCP server tools', () => {
|
describe('MCP server tools', () => {
|
||||||
it('lists all six tools with descriptions and input schemas', async () => {
|
it('lists all seven tools with descriptions and input schemas', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
const mcp = await connectMcp(env.svc);
|
const mcp = await connectMcp(env.svc);
|
||||||
try {
|
try {
|
||||||
const { tools } = await mcp.client.listTools();
|
const { tools } = await mcp.client.listTools();
|
||||||
expect(tools.map((t) => t.name).sort()).toEqual([
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||||
|
'device_info',
|
||||||
'get_messages',
|
'get_messages',
|
||||||
'get_node',
|
'get_node',
|
||||||
'list_channels',
|
'list_channels',
|
||||||
@@ -215,7 +216,7 @@ describe('MCP server tools', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list_channels, list_nodes and get_messages return the service data', async () => {
|
it('list_channels, list_nodes, get_messages and device_info return the service data', async () => {
|
||||||
const env = await connectedService();
|
const env = await connectedService();
|
||||||
try {
|
try {
|
||||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
@@ -234,6 +235,15 @@ describe('MCP server tools', () => {
|
|||||||
),
|
),
|
||||||
).toEqual([]); // no channel frames fed yet
|
).toEqual([]); // no channel frames fed yet
|
||||||
|
|
||||||
|
// no metadata/config frames fed yet → empty sections, null node num
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
toolText(
|
||||||
|
await mcp.client.callTool({ name: 'device_info', arguments: {} }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({ myNodeNum: null, localConfig: {}, moduleConfig: {} });
|
||||||
|
|
||||||
const nodes = JSON.parse(
|
const nodes = JSON.parse(
|
||||||
toolText(
|
toolText(
|
||||||
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
|
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ export function createMeshMcpServer(mesh: MeshService): McpServer {
|
|||||||
async () => jsonResult(mesh.messages()),
|
async () => jsonResult(mesh.messages()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'device_info',
|
||||||
|
{
|
||||||
|
description: `Device info captured during the initial sync: this device's node number, firmware version and metadata, plus per-section config/module-config (security publicKey/privateKey/adminKey and network wifiPsk redacted as "REDACTED"). In-memory only, reset on every bridge reconnect.${CHANNEL_NOTE}`,
|
||||||
|
},
|
||||||
|
async () => jsonResult(mesh.deviceInfo()),
|
||||||
|
);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import type { DeviceInfo } from './mesh.service.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
export class SendTextDto {
|
export class SendTextDto {
|
||||||
@@ -104,6 +105,51 @@ export class MeshController {
|
|||||||
return this.mesh.channels();
|
return this.mesh.channels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('device')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Device info (metadata + config, keys redacted)',
|
||||||
|
description:
|
||||||
|
'This device\'s node number, firmware version and metadata, plus per-section config and module-config messages captured during the initial sync; empty until it completes. Key material is never exposed: security publicKey/privateKey/adminKey and network wifiPsk are replaced with "REDACTED". In-memory only, reset on every bridge reconnect.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Device info',
|
||||||
|
example: {
|
||||||
|
myNodeNum: 123456789,
|
||||||
|
firmwareVersion: '2.7.0.1',
|
||||||
|
metadata: {
|
||||||
|
firmwareVersion: '2.7.0.1',
|
||||||
|
deviceStateVersion: 1,
|
||||||
|
canShutdown: true,
|
||||||
|
hasWifi: false,
|
||||||
|
hasBluetooth: true,
|
||||||
|
hasEthernet: false,
|
||||||
|
role: 3,
|
||||||
|
positionFlags: 65,
|
||||||
|
hwModel: 42,
|
||||||
|
hasRemoteHardware: false,
|
||||||
|
hasPKC: true,
|
||||||
|
excludedModules: 0,
|
||||||
|
},
|
||||||
|
localConfig: {
|
||||||
|
device: { role: 3, buttonGpio: 13 },
|
||||||
|
security: {
|
||||||
|
publicKey: 'REDACTED',
|
||||||
|
privateKey: 'REDACTED',
|
||||||
|
adminKey: 'REDACTED',
|
||||||
|
isManaged: true,
|
||||||
|
},
|
||||||
|
network: { wifiEnabled: false, wifiSsid: '', wifiPsk: 'REDACTED' },
|
||||||
|
},
|
||||||
|
moduleConfig: {
|
||||||
|
telemetry: { environmentUpdateInterval: 900 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
device(): DeviceInfo {
|
||||||
|
return this.mesh.deviceInfo();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('messages')
|
@Get('messages')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Message history (last 100)',
|
summary: 'Message history (last 100)',
|
||||||
@@ -141,7 +187,7 @@ export class MeshController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Known mesh nodes (lean summaries)',
|
summary: 'Known mesh nodes (lean summaries)',
|
||||||
description:
|
description:
|
||||||
'Node database collected during the initial sync plus live POSITION_APP updates; reset on every bridge reconnect. Names, hwModel and batteryLevel appear only when the device reported them.',
|
'Node database collected during the initial sync plus live POSITION_APP updates; reset on every bridge reconnect. Names, hwModel and batteryLevel appear only when the device reported them; snr/hopsAway/viaMqtt only when carried by the NodeInfo.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -155,6 +201,9 @@ export class MeshController {
|
|||||||
batteryLevel: 87,
|
batteryLevel: 87,
|
||||||
lastHeard: 1758000000,
|
lastHeard: 1758000000,
|
||||||
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
|
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
||||||
import {
|
import {
|
||||||
KAMIL_NUM,
|
KAMIL_NUM,
|
||||||
|
configSectionFrame,
|
||||||
kamilNodeInfo,
|
kamilNodeInfo,
|
||||||
|
metadataFrame,
|
||||||
|
moduleConfigSectionFrame,
|
||||||
nodeInfoFrame,
|
nodeInfoFrame,
|
||||||
startBridge,
|
startBridge,
|
||||||
waitFor,
|
waitFor,
|
||||||
@@ -614,3 +617,143 @@ describe('MeshService resolveDest', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MeshService device info', () => {
|
||||||
|
it('captures DeviceMetadata and exposes firmwareVersion', 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
// before the sync: only the node number (null until myInfo arrives)
|
||||||
|
expect(svc.deviceInfo()).toEqual({
|
||||||
|
myNodeNum: null,
|
||||||
|
localConfig: {},
|
||||||
|
moduleConfig: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
metadataFrame({ firmwareVersion: '2.7.0.1', hasWifi: false, role: 3 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.deviceInfo().firmwareVersion === '2.7.0.1');
|
||||||
|
const info = svc.deviceInfo();
|
||||||
|
expect(info.firmwareVersion).toBe('2.7.0.1');
|
||||||
|
expect(info.metadata?.firmwareVersion).toBe('2.7.0.1'); // metadata stored as-is
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures config/module-config sections and redacts key material', 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
const secret = 'super-secret-wifi-psk';
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
configSectionFrame('security', {
|
||||||
|
publicKey: new Uint8Array([1, 2, 3]),
|
||||||
|
privateKey: new Uint8Array([4, 5, 6]),
|
||||||
|
adminKey: [new Uint8Array([7])],
|
||||||
|
isManaged: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
configSectionFrame('network', {
|
||||||
|
wifiEnabled: false,
|
||||||
|
wifiSsid: 'home',
|
||||||
|
wifiPsk: secret,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bridge.sockets[0].write(configSectionFrame('device', { role: 3 }));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
moduleConfigSectionFrame('telemetry', {
|
||||||
|
environmentUpdateInterval: 900,
|
||||||
|
powerMeasurementEnabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
Object.keys(svc.deviceInfo().localConfig).length === 3 &&
|
||||||
|
Object.keys(svc.deviceInfo().moduleConfig).length === 1,
|
||||||
|
);
|
||||||
|
const info = svc.deviceInfo();
|
||||||
|
expect(Object.keys(info.localConfig).sort()).toEqual([
|
||||||
|
'device',
|
||||||
|
'network',
|
||||||
|
'security',
|
||||||
|
]);
|
||||||
|
expect(info.moduleConfig.telemetry).toMatchObject({
|
||||||
|
environmentUpdateInterval: 900,
|
||||||
|
powerMeasurementEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// key material is replaced with a marker — never exposed
|
||||||
|
expect(info.localConfig.security).toMatchObject({
|
||||||
|
publicKey: 'REDACTED',
|
||||||
|
privateKey: 'REDACTED',
|
||||||
|
adminKey: 'REDACTED',
|
||||||
|
isManaged: true, // non-secret fields pass through
|
||||||
|
});
|
||||||
|
expect(info.localConfig.network?.wifiPsk).toBe('REDACTED');
|
||||||
|
const json = JSON.stringify(info);
|
||||||
|
expect(json).not.toContain(secret);
|
||||||
|
} 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';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new MeshService();
|
||||||
|
try {
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
nodeInfoFrame(42, {
|
||||||
|
user: { id: '!ot', longName: 'Other Node' },
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 2);
|
||||||
|
const nodes = await svc.nodes();
|
||||||
|
expect(nodes.find((n) => n.num === 42)).toEqual({
|
||||||
|
num: 42,
|
||||||
|
longName: 'Other Node',
|
||||||
|
lastHeard: null,
|
||||||
|
snr: -2.5,
|
||||||
|
hopsAway: 1,
|
||||||
|
viaMqtt: true,
|
||||||
|
});
|
||||||
|
// absent in the proto → omitted from the summary
|
||||||
|
expect(nodes.find((n) => n.num === KAMIL_NUM)).not.toHaveProperty('snr');
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,35 @@ export interface NodeSummary {
|
|||||||
/** epoch seconds of the last heard packet; null when never heard */
|
/** epoch seconds of the last heard packet; null when never heard */
|
||||||
lastHeard: number | null;
|
lastHeard: number | null;
|
||||||
position?: { lat: number; lon: number; alt?: number };
|
position?: { lat: number; lon: number; alt?: number };
|
||||||
|
/** signal-to-noise ratio of the last received packet, dB (NodeInfo.snr) */
|
||||||
|
snr?: number;
|
||||||
|
/** hops from this node to the one that reported it (optional in proto) */
|
||||||
|
hopsAway?: number;
|
||||||
|
/** true when the node info arrived via MQTT rather than LoRa */
|
||||||
|
viaMqtt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of deviceInfo() output: sync-captured device data, key material redacted. */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
myNodeNum: number | null;
|
||||||
|
firmwareVersion?: string;
|
||||||
|
metadata?: Proto.Mesh.DeviceMetadata;
|
||||||
|
localConfig: Record<string, Record<string, unknown>>;
|
||||||
|
moduleConfig: Record<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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). */
|
||||||
|
function redactSection(
|
||||||
|
name: string,
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (name !== 'security' && name !== 'network') return value;
|
||||||
|
const out = { ...value };
|
||||||
|
for (const key of ['publicKey', 'privateKey', 'adminKey', 'wifiPsk'])
|
||||||
|
if (out[key] !== undefined) out[key] = REDACTED;
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -88,6 +117,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
>();
|
>();
|
||||||
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
||||||
private nodeMap = new Map<number, NodeEntry>();
|
private nodeMap = new Map<number, NodeEntry>();
|
||||||
|
// device metadata + per-section config/module-config streamed during the initial sync
|
||||||
|
private metadata: Proto.Mesh.DeviceMetadata | null = null;
|
||||||
|
private configSections = new Map<string, Record<string, unknown>>();
|
||||||
|
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
||||||
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
||||||
private history: MessageRecord[] = [];
|
private history: MessageRecord[] = [];
|
||||||
private pendingTx = new Map<number, MessageRecord>();
|
private pendingTx = new Map<number, MessageRecord>();
|
||||||
@@ -132,9 +165,33 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device info captured during the initial sync: this device's node number, firmware version and
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
deviceInfo(): DeviceInfo {
|
||||||
|
const out: DeviceInfo = {
|
||||||
|
myNodeNum: this.myNodeNum,
|
||||||
|
localConfig: {},
|
||||||
|
moduleConfig: {},
|
||||||
|
};
|
||||||
|
if (this.metadata) {
|
||||||
|
out.firmwareVersion = this.metadata.firmwareVersion;
|
||||||
|
out.metadata = this.metadata;
|
||||||
|
}
|
||||||
|
for (const [name, value] of this.configSections)
|
||||||
|
out.localConfig[name] = redactSection(name, value);
|
||||||
|
for (const [name, value] of this.moduleConfigSections)
|
||||||
|
out.moduleConfig[name] = value;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lean summaries of all known nodes, sorted by node number.
|
* Lean summaries of all known nodes, sorted by node number.
|
||||||
* Names/hwModel/batteryLevel appear only when the device reported them;
|
* Names/hwModel/batteryLevel/snr/hopsAway/viaMqtt appear only when the device reported them;
|
||||||
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
|
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
|
||||||
*/
|
*/
|
||||||
async nodes(): Promise<NodeSummary[]> {
|
async nodes(): Promise<NodeSummary[]> {
|
||||||
@@ -247,9 +304,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// omit UNSET (enum value 0) rather than report "UNSET"
|
// omit UNSET (enum value 0) rather than report "UNSET"
|
||||||
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
|
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
|
||||||
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
|
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
|
||||||
// optional proto field: undefined when the device never reported metrics
|
// optional proto fields: undefined when the device never reported them
|
||||||
const battery = n.deviceMetrics?.batteryLevel;
|
const battery = n.deviceMetrics?.batteryLevel;
|
||||||
if (battery !== undefined) s.batteryLevel = battery;
|
if (battery !== undefined) s.batteryLevel = battery;
|
||||||
|
// non-optional proto scalars decode with their defaults (snr=0, viaMqtt=false), so a default value means "not reported"
|
||||||
|
if (n.snr !== undefined && n.snr !== 0) s.snr = n.snr;
|
||||||
|
if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; // optional in proto → truly absent when not reported
|
||||||
|
if (n.viaMqtt === true) s.viaMqtt = true;
|
||||||
const p = n.position;
|
const p = n.position;
|
||||||
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
|
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
|
||||||
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
|
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
|
||||||
@@ -265,9 +326,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
// fresh sync on every (re)connect — drop stale channels/nodes/history from the old session
|
// fresh sync on every (re)connect — drop stale channels/nodes/device info/history from the old session
|
||||||
this.channelMap = new Map();
|
this.channelMap = new Map();
|
||||||
this.nodeMap = new Map();
|
this.nodeMap = new Map();
|
||||||
|
this.metadata = null;
|
||||||
|
this.configSections.clear();
|
||||||
|
this.moduleConfigSections.clear();
|
||||||
this.history = [];
|
this.history = [];
|
||||||
this.pendingTx.clear();
|
this.pendingTx.clear();
|
||||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||||
@@ -328,6 +392,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// device streams the whole node DB during initial sync
|
// device streams the whole node DB during initial sync
|
||||||
this.nodeMap.set(v.value.num, v.value);
|
this.nodeMap.set(v.value.num, v.value);
|
||||||
break;
|
break;
|
||||||
|
case 'metadata':
|
||||||
|
// DeviceMetadata streamed once during initial sync (no key material in it)
|
||||||
|
this.metadata = v.value;
|
||||||
|
break;
|
||||||
|
case 'config': {
|
||||||
|
// device streams one Config per section during initial sync
|
||||||
|
const sec = v.value.payloadVariant;
|
||||||
|
if (sec?.case) this.configSections.set(sec.case, sec.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'moduleConfig': {
|
||||||
|
const mod = v.value.payloadVariant;
|
||||||
|
if (mod?.case) this.moduleConfigSections.set(mod.case, mod.value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'configCompleteId':
|
case 'configCompleteId':
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
|
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
|
||||||
|
|||||||
Reference in New Issue
Block a user