Compare commits

..
5 Commits
Author SHA1 Message Date
kamilkrzywda 57ad27d66f Message history with delivery tracking (GET /api/messages) (#8)
CI / build + unit (push) Successful in 32s
2026-09-24 16:19:32 +02:00
kamilkrzywda 5292303786 Node database: collect nodes during sync, expose GET /api/nodes (#7)
CI / build + unit (push) Successful in 39s
2026-09-24 15:37:03 +02:00
kamilkrzywda adfbc9800b Send want_config_id on connect to trigger initial sync (#6)
CI / build + unit (push) Successful in 39s
Send want_config_id on connect to trigger initial sync (#6)

Firmware only streams myInfo/channels/configComplete after receiving a
ToRadio with want_config_id — the service previously wrote nothing on
connect, so /api/channels stayed [] and myNodeNum null. v0.1.4.
2026-09-23 22:42:03 +02:00
kamilkrzywda 3062f40899 feat: pin sends to testowy channel (index 2), never primary (v0.1.3) (#5)
CI / build + unit (push) Successful in 36s
feat: pin sends to testowy channel (index 2), never primary (v0.1.3)
2026-09-23 22:06:01 +02:00
kamilkrzywda 266aadf635 ci: Gitea Actions test pipeline (v0.1.2) (#4)
CI / build + unit (push) Successful in 40s
ci: Gitea Actions test pipeline (v0.1.2)
2026-09-23 21:53:57 +02:00
10 changed files with 1024 additions and 23 deletions
+69
View File
@@ -0,0 +1,69 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
name: build + unit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The runner provides a working docker CLI out of the box (full-dind) — no endpoint
# resolution needed, same as flight's CI.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
# Job-level dep cache via the runner's built-in cache server. continue-on-error: an
# unreachable/broken cache server must never break the run (flight convention). npm ci
# wipes node_modules, so cache npm's own tarball store instead — restoring node_modules
# would just be deleted again. No explicit save step: caches marked by the restore step
# are uploaded automatically at job end.
- name: Restore deps cache
uses: actions/cache@v4
continue-on-error: true
with:
path: $HOME/.npm
key: ${{ runner.os }}-deps-npm-${{ hashFiles('package-lock.json') }}
# Retried: the runner's outbound network/DNS is flaky (transient EAI_AGAIN seen in CI).
- name: Install dependencies
run: |
set -u
ok=0
for i in 1 2 3; do
if npm ci; then ok=1; break; fi
echo "::warning::dependency install attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::dependency install failed after 3 attempts" >&2; exit 1; }
- name: Build
run: npm run build
# vitest auto-appends results to $GITHUB_STEP_SUMMARY when GITHUB_ACTIONS=true — no
# extra summary step needed (same as flight relies on for its unit stage).
- name: Unit tests
run: npm test
# Verifies the production Dockerfile builds — the deploy path is `docker compose up -d --build`.
# Build only, nothing runs, so no cleanup/prune steps are needed (unlike flight's e2e stack).
# Retried: same flaky runner network as dep install.
- name: Docker image build
run: |
set -u
ok=0
for i in 1 2 3; do
if docker compose build --progress=plain; then ok=1; break; fi
echo "::warning::docker build attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::docker image build failed after 3 attempts" >&2; exit 1; }
+1 -1
View File
@@ -17,7 +17,7 @@
## 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 | "!b" | "broadcast"> }`, returns `{ id }`), and `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. 2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, 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 2 (`testowy`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `2`).
## Other ## Other
+26
View File
@@ -1,5 +1,31 @@
# Changelog # Changelog
## v0.3.0 — 2026-09-24 14:08
- Message history with delivery tracking, exposed as `GET /api/messages` (newest first): inbound TEXT_MESSAGE_APP packets (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` when the routing ACK/NACK arrives — ROUTING_APP packets carry a serialized `meshtastic.Routing` in `Data.payload` whose oneof variant is `errorReason` (`Routing_Error`: 0 NONE = delivered, anything else failed with a human-readable `ackError`, e.g. NO_ROUTE/GOT_NAK/TIMEOUT, fallback `reason <n>`), matched to our sends via `Data.requestId`. Broadcasts stay `pending` because firmware strips want_ack pre-TX (documented behavior, no timer). History is a bounded in-memory buffer of the last 100 records (newest kept) and resets on every bridge reconnect alongside channels/nodes.
- Codec: new `PORT_ROUTING_APP = 5` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodeRouting()` for ROUTING_APP payloads, and `describeRoutingError()` which maps a Routing.Error code to its name from the installed package's `Mesh.Routing_Error` enum.
- Swagger: `GET /api/messages` documented with the pending→delivered/failed lifecycle, the broadcast caveat, and an example matching the record shape; README + AGENTS.md endpoint lists updated.
## v0.2.0 — 2026-09-24 13:08
- Node database collected during the initial sync (`FromRadio.nodeInfo` frames, upserted by node number) and kept fresh by live `POSITION_APP` packets (the Position is serialized in `Data.payload`; unknown senders get a stub entry). The map resets on every bridge reconnect alongside channels.
- New endpoints: `GET /api/nodes` — lean summaries sorted by node number as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard (epoch secs | null), position? { lat, lon, alt? } }[]`; names/hwModel/battery appear only when the device reported them, `hwModel` is the enum's string name (UNSET omitted), and `position` only when both coordinates are known. `GET /api/nodes/:id` — lookup by exact node number (numbers/numeric strings), then case-insensitive exact long/short name, else unique case-insensitive substring; 404 with a clear message when nothing or more than one node matches.
- Codec: new `PORT_POSITION_APP = 3` constant (verified against the installed `@meshtastic/protobufs` PortNum enum), `decodePosition()` for POSITION_APP payloads, and `loadHwModelNames()` reverse lookup of the HardwareModel enum.
- Swagger docs made genuinely useful: `Mesh` tag on the controller (description via `DocumentBuilder.addTag` — v12's `ApiTagOptions` has no description field) plus `@ApiOperation` summary/description and `@ApiResponse` examples on all endpoints, existing and new.
- Unit tests build real framed FromRadio bytes for nodeInfo/POSITION_APP: sync collection + summary shape (lat/lon = value × 1e-7, battery), stub entry from an unknown POSITION_APP sender, lookup ladder (exact num / numeric string / exact name / unique substring) with ambiguous and missing → null, and reconnect reset.
## v0.1.4 — 2026-09-23 20:33
- Send a `ToRadio` with `want_config_id` (random non-zero uint32 nonce) on every bridge connect, so the firmware streams its initial sync data (`myInfo`, channels, `config_complete_id`) — previously nothing was written on connect, which is why `/api/channels` stayed empty and `myNodeNum` stayed null in production. New codec helper `buildWantConfigToRadio`; unit test captures the outbound frame on the fake bridge, asserts the `wantConfigId` oneof case with a non-zero value, then feeds back `myInfo` + `configCompleteId` frames and checks service state.
## v0.1.3 — 2026-09-23 19:58
- Sends are now pinned to the test channel `testowy` (channel index 2) instead of the hardcoded primary channel (`channel: 0`). The target channel is configurable via `MESH_SEND_CHANNEL` (default `2`) and parsed once at service construction; `buildTextToRadio` takes the channel index as a parameter. Unit tests now decode the outbound ToRadio frame on the fake bridge and assert `packet.channel === 2` on the default send path.
## v0.1.2 — 2026-09-23 19:51
- Added a Gitea Actions CI pipeline (`.gitea/workflows/ci.yml`), mirroring flight's runner conventions: on push/PR to `main` (plus manual dispatch) one job runs `npm ci`, `nest build`, the vitest unit suite, and a production Docker image build — with retry loops for the runner's flaky uplink. No app code changed.
## v0.1.1 — 2026-09-23 19:38 ## v0.1.1 — 2026-09-23 19:38
- Fixed the default bridge port to `4403`, the meshtastic-serial-bridge's actual TCP port (the old value pointed at a closed port). Updated the `MESH_PORT` default in code, the docker-compose environment, and docs. - Fixed the default bridge port to `4403`, the meshtastic-serial-bridge's actual TCP port (the old value pointed at a closed port). Updated the `MESH_PORT` default in code, the docker-compose environment, and docs.
+14 -2
View File
@@ -1,8 +1,10 @@
# mesh-bot # mesh-bot
Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a Minimal NestJS bot for Meshtastic. Long-running service that TCP-connects to a
Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, parses inbound Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, sends
protobuf frames, logs received text messages, and exposes HTTP endpoints to send text. `want_config_id` on connect so the firmware streams its initial sync data,
parses inbound protobuf frames, logs received text messages, and exposes HTTP
endpoints to send text.
## Local run ## Local run
@@ -26,6 +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 | "!b" | "broadcast"> }`, returns `{ id }` - `POST /api/send` — body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`
- `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/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/: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
`/docs-json`. `/docs-json`.
@@ -35,15 +40,22 @@ 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/messages
curl http://localhost:3000/api/nodes
curl http://localhost:3000/api/nodes/kamil
``` ```
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`. `to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
All sends always target channel index 2 (`testowy`) — never the primary
channel. Override with `MESH_SEND_CHANNEL`.
## Env vars ## Env vars
- `MESH_HOST` (default `192.168.1.4`) — bridge host - `MESH_HOST` (default `192.168.1.4`) — bridge host
- `MESH_PORT` (default `4403`) — bridge port - `MESH_PORT` (default `4403`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
- `MESH_SEND_CHANNEL` (default `2`) — channel index all sends target (`testowy`; never the primary channel)
- `PORT` (default `3000`) — HTTP listen port - `PORT` (default `3000`) — HTTP listen port
Note: the serial bridge accepts only ONE client at a time — if another app is Note: the serial bridge accepts only ONE client at a time — if another app is
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.1.1", "version": "0.3.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2 -1
View File
@@ -15,8 +15,9 @@ async function bootstrap() {
const docConfig = new DocumentBuilder() const docConfig = new DocumentBuilder()
.setTitle('MeshBot API') .setTitle('MeshBot API')
.setDescription( .setDescription(
'Meshtastic bridge bot: status, send text messages, list device channels.', 'Meshtastic bridge bot: status, send text messages, list device channels and known nodes.',
) )
.addTag('Mesh', 'Meshtastic bridge control and telemetry')
.setVersion(pkg.version) .setVersion(pkg.version)
.build(); .build();
const document = SwaggerModule.createDocument(app, docConfig); const document = SwaggerModule.createDocument(app, docConfig);
+56 -1
View File
@@ -10,6 +10,10 @@ async function loadProto(): Promise<typeof import('@meshtastic/protobufs')> {
export const BROADCAST_ADDR = 0xffffffff; export const BROADCAST_ADDR = 0xffffffff;
// official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins) // official portnums.proto: TEXT_MESSAGE_APP=1 (task spec draft said 3 — the enum value wins)
export const PORT_TEXT_MESSAGE_APP = 1; export const PORT_TEXT_MESSAGE_APP = 1;
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
export const PORT_POSITION_APP = 3;
// verified against installed @meshtastic/protobufs Portnums.PortNum.ROUTING_APP
export const PORT_ROUTING_APP = 5;
const START1 = 0x94; const START1 = 0x94;
const START2 = 0xc3; const START2 = 0xc3;
@@ -61,9 +65,50 @@ export async function decodeFromRadio(
return fromBinary(P.Mesh.FromRadioSchema, bytes); return fromBinary(P.Mesh.FromRadioSchema, bytes);
} }
/** POSITION_APP packets carry a serialized meshtastic.Position in Data.payload. */
export async function decodePosition(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Mesh.Position> {
const P = await loadProto();
return fromBinary(P.Mesh.PositionSchema, bytes);
}
/** ROUTING_APP packets carry a serialized meshtastic.Routing in Data.payload (ACK/NACK for want_ack sends). */
export async function decodeRouting(
bytes: Uint8Array,
): Promise<import('@meshtastic/protobufs').Mesh.Routing> {
const P = await loadProto();
return fromBinary(P.Mesh.RoutingSchema, 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();
const hit = Object.entries(P.Mesh.Routing_Error).find(
([, v]) => typeof v === 'number' && v === code,
);
return hit ? String(hit[0]) : `reason ${code}`;
}
let hwModelNames: Map<number, string> | null = null;
/** Reverse lookup of meshtastic.HardwareModel (enum value -> name), loaded once. */
export async function loadHwModelNames(): Promise<Map<number, string>> {
if (!hwModelNames) {
const P = await loadProto();
hwModelNames = new Map(
Object.entries(P.Mesh.HardwareModel).map(([k, v]) => [
Number(k),
String(v),
]),
);
}
return hwModelNames;
}
export async function buildTextToRadio( export async function buildTextToRadio(
to: number, to: number,
text: string, text: string,
channelIndex: number,
): Promise<{ bytes: Uint8Array; id: number }> { ): Promise<{ bytes: Uint8Array; id: number }> {
const P = await loadProto(); const P = await loadProto();
let id = Math.floor(Math.random() * 0x100000000) >>> 0; let id = Math.floor(Math.random() * 0x100000000) >>> 0;
@@ -72,7 +117,7 @@ export async function buildTextToRadio(
to, to,
id, id,
wantAck: true, wantAck: true,
channel: 0, channel: channelIndex,
payloadVariant: { payloadVariant: {
case: 'decoded', case: 'decoded',
value: { value: {
@@ -87,3 +132,13 @@ export async function buildTextToRadio(
}); });
return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id }; return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id };
} }
export async function buildWantConfigToRadio(
wantConfigId: number,
): Promise<Uint8Array> {
const P = await loadProto();
const toRadio = create(P.Mesh.ToRadioSchema, {
payloadVariant: { case: 'wantConfigId', value: wantConfigId },
});
return toBinary(P.Mesh.ToRadioSchema, toRadio);
}
+136 -3
View File
@@ -3,9 +3,16 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
NotFoundException,
Param,
Post, Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiProperty } from '@nestjs/swagger'; import {
ApiOperation,
ApiProperty,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { BROADCAST_ADDR } from './codec.js'; import { BROADCAST_ADDR } from './codec.js';
import { MeshService } from './mesh.service.js'; import { MeshService } from './mesh.service.js';
@@ -21,18 +28,45 @@ export class SendTextDto {
to?: string | number; to?: string | number;
} }
// tag description is set via DocumentBuilder.addTag in main.ts (v12 ApiTagOptions has no description)
@ApiTags('Mesh')
@Controller() @Controller()
export class MeshController { export class MeshController {
constructor(private readonly mesh: MeshService) {} constructor(private readonly mesh: MeshService) {}
@Get('status') @Get('status')
@ApiOperation({ summary: 'Bridge connection status' }) @ApiOperation({
summary: 'Bridge connection status',
description:
'Whether the bot is connected to the meshtastic-serial-bridge and which node number this device has.',
})
@ApiResponse({
status: 200,
description: 'Connection status',
example: {
connected: true,
myNodeNum: 123456789,
host: '192.168.1.4',
port: 4403,
},
})
status() { status() {
return this.mesh.status(); return this.mesh.status();
} }
@Post('send') @Post('send')
@ApiOperation({ summary: 'Send a text message over the mesh' }) @ApiOperation({
summary: 'Send a text message over the mesh',
description:
'Queues a TEXT_MESSAGE_APP packet on the pinned test channel (index 2, "testowy" — never the primary channel). Returns the Meshtastic packet id.',
})
@ApiResponse({
status: 200,
description: 'Packet queued',
example: { id: 123456789 },
})
@ApiResponse({ status: 400, description: 'Invalid text or target' })
@ApiResponse({ status: 503, description: 'Bridge not connected' })
async send(@Body() body: SendTextDto) { async send(@Body() body: SendTextDto) {
if (typeof body?.text !== 'string' || body.text.length === 0) { if (typeof body?.text !== 'string' || body.text.length === 0) {
throw new BadRequestException('text must be a non-empty string'); throw new BadRequestException('text must be a non-empty string');
@@ -53,8 +87,107 @@ export class MeshController {
@Get('channels') @Get('channels')
@ApiOperation({ @ApiOperation({
summary: "The device's Meshtastic channels (keys redacted)", summary: "The device's Meshtastic channels (keys redacted)",
description:
'Channel table streamed during the initial sync; empty until it completes. psk/keys are never exposed.',
})
@ApiResponse({
status: 200,
description: 'Channels sorted by index',
example: [
{ index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true },
{
index: 2,
name: 'testowy',
uplinkEnabled: false,
downlinkEnabled: false,
},
],
}) })
channels() { channels() {
return this.mesh.channels(); return this.mesh.channels();
} }
@Get('messages')
@ApiOperation({
summary: 'Message history (last 100)',
description:
'Inbound text messages and our sends, newest first. tx records start "pending" and move to "delivered"/"failed" when the routing ACK/NACK arrives; broadcasts stay "pending" because firmware strips want_ack before TX. History is in-memory only (last 100) and resets on every bridge reconnect.',
})
@ApiResponse({
status: 200,
description: 'Messages newest first',
example: [
{
id: 456789,
dir: 'tx',
to: 42,
text: 'hello testowy',
channel: 2,
at: 1758000123456,
status: 'delivered',
},
{
id: 123456,
dir: 'rx',
from: 987654321,
text: 'ping',
channel: 2,
at: 1758000000000,
},
],
})
messages() {
return this.mesh.messages();
}
@Get('nodes')
@ApiOperation({
summary: 'Known mesh nodes (lean summaries)',
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.',
})
@ApiResponse({
status: 200,
description: 'Nodes sorted by node number',
example: [
{
num: 123456789,
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: 'TBEAM',
batteryLevel: 87,
lastHeard: 1758000000,
position: { lat: 52.1049637, lon: 20.9833152, alt: 123 },
},
],
})
async nodes() {
return this.mesh.nodes();
}
@Get('nodes/:id')
@ApiOperation({
summary: 'Look up one node by number or name',
description:
'Exact node number first (numbers and numeric strings), then case-insensitive exact long/short name, else the unique case-insensitive substring match across names. 404 when nothing or more than one node matches.',
})
@ApiResponse({
status: 200,
description: 'The matching node',
example: {
num: 123456789,
longName: 'Kamil SP8MK/P',
shortName: 'KK',
lastHeard: null,
},
})
@ApiResponse({
status: 404,
description: 'No node (or more than one) matching the given id or name',
})
async node(@Param('id') id: string) {
const n = await this.mesh.node(id);
if (!n) throw new NotFoundException(`no node matching '${id}'`);
return n;
}
} }
+502 -6
View File
@@ -1,18 +1,21 @@
import { create, toBinary } from '@bufbuild/protobuf'; import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs'; import * as P from '@meshtastic/protobufs';
import * as net from 'node:net'; import * as net from 'node:net';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { frame } from './codec.js'; import { extractFrames, frame } from './codec.js';
import { MeshService } from './mesh.service.js'; import { MeshService } from './mesh.service.js';
function startBridge(): Promise<{ function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
port: number; port: number;
sockets: net.Socket[]; sockets: net.Socket[];
close: () => void; close: () => void;
}> { }> {
const sockets: net.Socket[] = []; const sockets: net.Socket[] = [];
const server = net.createServer((socket) => sockets.push(socket)); const server = net.createServer((socket) => {
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
return new Promise((resolve) => { return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => { server.listen(0, '127.0.0.1', () => {
resolve({ resolve({
@@ -24,14 +27,131 @@ function startBridge(): Promise<{
}); });
} }
async function waitFor(cond: () => boolean, timeoutMs = 5000): Promise<void> { async function waitFor(
cond: () => boolean | Promise<boolean>,
timeoutMs = 5000,
): Promise<void> {
const t0 = Date.now(); const t0 = Date.now();
while (!cond()) { while (!(await cond())) {
if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting'); if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting');
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
} }
} }
function nodeInfoFrame(
num: number,
fields?: Partial<P.Mesh.NodeInfo>,
): Uint8Array {
const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields });
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'nodeInfo', value: ni },
}),
),
);
}
// live position update: POSITION_APP packet with a serialized Position in Data.payload
function positionPacketFrame(
from: number,
pos: Partial<P.Mesh.Position>,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.POSITION_APP,
payload: toBinary(
P.Mesh.PositionSchema,
create(P.Mesh.PositionSchema, pos),
),
});
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 },
}),
),
);
}
const KAMIL_NUM = 123456789;
// inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload
function textPacketFrame(
from: number,
to: number,
text: string,
channel = 0,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP,
payload: new TextEncoder().encode(text),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from,
to,
id: 1,
channel,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
// ACK/NACK for our sends: ROUTING_APP packet with Data.requestId = original id and a serialized Routing in the payload
function routingFrame(
requestId: number,
errorReason: P.Mesh.Routing_Error,
): Uint8Array {
const data = create(P.Mesh.DataSchema, {
portnum: P.Portnums.PortNum.ROUTING_APP,
requestId,
payload: toBinary(
P.Mesh.RoutingSchema,
create(P.Mesh.RoutingSchema, {
variant: { case: 'errorReason', value: errorReason },
}),
),
});
const pkt = create(P.Mesh.MeshPacketSchema, {
from: 1,
id: 2,
payloadVariant: { case: 'decoded', value: data },
});
return frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'packet', value: pkt },
}),
),
);
}
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
return {
user: {
id: '!kamil',
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: P.Mesh.HardwareModel.TBEAM,
},
position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 },
lastHeard: 1758000000,
deviceMetrics: { batteryLevel: 87 },
};
}
function channelFrame( function channelFrame(
index: number, index: number,
settings?: { name?: string; uplinkEnabled?: boolean }, settings?: { name?: string; uplinkEnabled?: boolean },
@@ -116,3 +236,379 @@ describe('MeshService channel pipeline', () => {
} }
}); });
}); });
describe('MeshService connect', () => {
it('sends want_config_id on connect and applies the initial sync', async () => {
const out: Uint8Array[] = [];
const bridge = await startBridge((s) => s.on('data', (c) => out.push(c)));
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(() => svc.status().connected && out.length > 0);
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
expect(packets.length).toBeGreaterThan(0);
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
expect(tr.payloadVariant?.case).toBe('wantConfigId');
const nonce =
tr.payloadVariant && tr.payloadVariant.case === 'wantConfigId'
? tr.payloadVariant.value
: -1;
expect(nonce).toBeGreaterThan(0);
// feed back the initial sync: myInfo, then configCompleteId echoing the nonce
bridge.sockets[0].write(
frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'myInfo', value: { myNodeNum: 1234 } },
}),
),
),
);
bridge.sockets[0].write(
frame(
toBinary(
P.Mesh.FromRadioSchema,
create(P.Mesh.FromRadioSchema, {
payloadVariant: { case: 'configCompleteId', value: nonce },
}),
),
),
);
await waitFor(() => svc.status().myNodeNum === 1234);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService send', () => {
it('sends on the test channel (index 2) by default, never primary', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
delete process.env.MESH_SEND_CHANNEL; // default path: "testowy" (index 2)
const svc = new MeshService();
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const out: Uint8Array[] = [];
bridge.sockets[0].on('data', (c) => out.push(c));
const id = await svc.sendText(42, 'hello testowy');
expect(id).toBeGreaterThan(0);
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0));
let off = 0;
for (const c of out) {
merged.set(c, off);
off += c.length;
}
const { packets } = extractFrames(merged);
// outbound now starts with the want_config_id frame — find the text packet
for (const p of packets) {
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
if (tr.payloadVariant?.case === 'packet')
return tr.payloadVariant.value;
}
return undefined;
};
await waitFor(() => readOutbound() !== undefined);
const pkt = readOutbound()!;
expect(pkt.channel).toBe(2); // "testowy" — never the primary channel (0)
expect(pkt.to).toBe(42);
const decoded =
pkt.payloadVariant?.case === 'decoded'
? pkt.payloadVariant.value
: undefined;
expect(
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
).toBe('hello testowy');
} finally {
delete process.env.MESH_SEND_CHANNEL;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService node database', () => {
it('collects NodeInfo during sync and summarizes nodes', 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(987654321)); // bare entry: no user/position/metrics yet
await waitFor(async () => (await svc.nodes()).length === 2);
expect(await svc.nodes()).toEqual([
{
num: KAMIL_NUM,
longName: 'Kamil SP8MK/P',
shortName: 'KK',
hwModel: 'TBEAM',
batteryLevel: 87,
lastHeard: 1758000000,
position: { lat: 521049637 * 1e-7, lon: 209833152 * 1e-7, alt: 123 },
},
{ num: 987654321, lastHeard: null }, // names/hwModel/battery/position omitted when absent
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('creates a stub entry for POSITION_APP packets from unknown nodes', 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(
positionPacketFrame(555, {
latitudeI: 10000000,
longitudeI: -20000000,
}),
);
await waitFor(async () => (await svc.nodes()).length === 1);
expect(await svc.nodes()).toEqual([
{ num: 555, lastHeard: null, position: { lat: 1, lon: -2 } }, // no alt sent -> omitted
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('looks up nodes by exact num, numeric string, exact name and unique substring', 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', shortName: 'OT' },
}),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect((await svc.node(KAMIL_NUM))?.longName).toBe('Kamil SP8MK/P'); // exact num
expect((await svc.node(String(KAMIL_NUM)))?.num).toBe(KAMIL_NUM); // numeric string
expect((await svc.node('kamil sp8mk/p'))?.shortName).toBe('KK'); // case-insensitive exact name
expect((await svc.node('kamil'))?.num).toBe(KAMIL_NUM); // unique substring
expect(await svc.node('zzz-nope')).toBeNull(); // no match
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('returns null for ambiguous name matches', 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,
);
// two nodes whose names both contain "kamil" and share the short name "KK"
bridge.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
bridge.sockets[0].write(
nodeInfoFrame(777, {
user: { id: '!kj', longName: 'Kamil Jr.', shortName: 'KK' },
}),
);
await waitFor(async () => (await svc.nodes()).length === 2);
expect(await svc.node('kamil')).toBeNull(); // substring matches two nodes
expect(await svc.node('kk')).toBeNull(); // exact name matches two nodes
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('resets the node database 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(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
await waitFor(async () => (await svc.nodes()).length === 1);
// drop the connection; bot reconnects and must start with an empty node DB
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
await waitFor(async () => (await svc.nodes()).length === 0);
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});
describe('MeshService message history', () => {
it('captures inbound text as rx records', 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(textPacketFrame(KAMIL_NUM, 42, 'ping', 2));
await waitFor(() => svc.messages().length === 1);
expect(svc.messages()).toEqual([
{
id: 1,
dir: 'rx',
from: KAMIL_NUM,
text: 'ping',
channel: 2,
at: expect.any(Number),
},
]);
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('tracks a tx send pending -> delivered on the routing ACK', 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 id = await svc.sendText(42, 'hello testowy');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
to: 42,
text: 'hello testowy',
channel: 2,
status: 'pending',
});
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE));
await waitFor(() => svc.messages()[0]?.status === 'delivered');
expect(svc.messages()[0]).toMatchObject({
id,
dir: 'tx',
status: 'delivered',
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('marks a tx failed with the routing error reason on NACK', 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 id = await svc.sendText(42, 'will fail');
expect(svc.messages()[0]?.status).toBe('pending');
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.TIMEOUT));
await waitFor(() => svc.messages()[0]?.status === 'failed');
expect(svc.messages()[0]).toMatchObject({
id,
status: 'failed',
ackError: 'TIMEOUT', // human-readable Routing.Error name
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('caps history 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(textPacketFrame(42, KAMIL_NUM, `m-${i}`));
await waitFor(() => svc.messages().length === 100, 10000);
expect(svc.messages()[0]?.text).toBe('m-104'); // newest first
expect(svc.messages()[99]?.text).toBe('m-5'); // m-0..m-4 dropped
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
+217 -8
View File
@@ -6,13 +6,60 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import * as net from 'node:net'; import * as net from 'node:net';
import type * as Proto from '@meshtastic/protobufs';
import { import {
PORT_POSITION_APP,
PORT_ROUTING_APP,
PORT_TEXT_MESSAGE_APP, PORT_TEXT_MESSAGE_APP,
buildTextToRadio, buildTextToRadio,
buildWantConfigToRadio,
decodeFromRadio, decodeFromRadio,
decodePosition,
decodeRouting,
describeRoutingError,
extractFrames, extractFrames,
frame,
loadHwModelNames,
} from './codec.js'; } from './codec.js';
/** One entry in the message history: an inbound text (dir 'rx') or one of our sends (dir 'tx'). */
export interface MessageRecord {
id: number;
dir: 'rx' | 'tx';
/** sender node num — rx only */
from?: number;
/** destination node num (broadcast addr for broadcasts) — tx only */
to?: number;
text: string;
channel: number;
/** epoch ms */
at: number;
/** tx only: pending until the routing ACK/NACK arrives; broadcasts stay pending (firmware strips want_ack pre-TX) */
status?: 'pending' | 'delivered' | 'failed';
/** tx only, when failed: human-readable Routing.Error reason */
ackError?: string;
}
// bounded history: keep the newest N records, in-memory only
const HISTORY_LIMIT = 100;
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
export interface NodeSummary {
num: number;
longName?: string;
shortName?: string;
/** meshtastic.HardwareModel enum name (e.g. "TBEAM"); omitted when unknown or UNSET */
hwModel?: string;
/** 0-100 = battery percent, >100 = mains powered (device convention) */
batteryLevel?: number;
/** epoch seconds of the last heard packet; null when never heard */
lastHeard: number | null;
position?: { lat: number; lon: number; alt?: number };
}
@Injectable() @Injectable()
export class MeshService implements OnModuleInit, OnModuleDestroy { export class MeshService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MeshService.name); private readonly logger = new Logger(MeshService.name);
@@ -23,6 +70,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
process.env.MESH_RECONNECT_MS ?? '5000', process.env.MESH_RECONNECT_MS ?? '5000',
10, 10,
); );
// sends are pinned to the test channel ("testowy", index 2) on purpose — never the primary channel
private readonly sendChannel = parseInt(
process.env.MESH_SEND_CHANNEL ?? '2',
10,
);
private socket: net.Socket | null = null; private socket: net.Socket | null = null;
private buffer: Uint8Array = new Uint8Array(0); private buffer: Uint8Array = new Uint8Array(0);
@@ -33,6 +85,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
number, number,
import('@meshtastic/protobufs').Channel.Channel import('@meshtastic/protobufs').Channel.Channel
>(); >();
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
private nodeMap = new Map<number, NodeEntry>();
// 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>();
private reconnectTimer: NodeJS.Timeout | null = null; private reconnectTimer: NodeJS.Timeout | null = null;
private shuttingDown = false; private shuttingDown = false;
@@ -46,6 +103,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.socket?.destroy(); this.socket?.destroy();
} }
/** Bridge connection state: `{ connected, myNodeNum, host, port }`. */
status() { status() {
return { return {
connected: this.connected, connected: this.connected,
@@ -56,6 +114,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
} }
// psk/keys are deliberately not included — never expose them over HTTP // psk/keys are deliberately not included — never expose them over HTTP
/** Device channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` sorted by index; empty until the initial sync completes. */
channels(): { channels(): {
index: number; index: number;
name: string; name: string;
@@ -72,23 +131,124 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
})); }));
} }
/**
* Lean summaries of all known nodes, sorted by node number.
* Names/hwModel/batteryLevel appear only when the device reported them;
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
*/
async nodes(): Promise<NodeSummary[]> {
const names = await loadHwModelNames();
return [...this.nodeMap.values()]
.sort((a, b) => a.num - b.num)
.map((n) => this.summarize(n, names));
}
/**
* Look up one node: exact node number first (numbers and numeric strings),
* then case-insensitive EXACT long/short name, else the unique case-insensitive
* SUBSTRING match across long/short names. Returns null when nothing or more
* than one node matches. Reused by send-by-name lookups.
*/
async node(idOrName: string | number): Promise<NodeSummary | null> {
const q = String(idOrName).trim().toLowerCase();
if (!q) return null;
const names = await loadHwModelNames();
if (/^\d+$/.test(q)) {
const hit = this.nodeMap.get(Number(q));
if (hit) return this.summarize(hit, names);
}
const all = [...this.nodeMap.values()];
// exact name match wins over substring; a string always contains itself, so an ambiguous exact set can never become unique below
const exact = all.filter(
(n) =>
n.user &&
(n.user.longName.toLowerCase() === q ||
n.user.shortName.toLowerCase() === q),
);
if (exact.length === 1) return this.summarize(exact[0], names);
const sub = all.filter(
(n) =>
n.user &&
(n.user.longName.toLowerCase().includes(q) ||
n.user.shortName.toLowerCase().includes(q)),
);
if (sub.length === 1) return this.summarize(sub[0], names);
return null;
}
/**
* Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx').
* tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;
* broadcasts stay 'pending' because firmware strips want_ack before TX. In-memory only, last 100, reset on reconnect.
*/
messages(): MessageRecord[] {
return [...this.history].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> { async sendText(to: number, text: string): Promise<number> {
if (!this.connected || !this.socket) if (!this.connected || !this.socket)
throw new ServiceUnavailableException('bridge not connected'); throw new ServiceUnavailableException('bridge not connected');
const { bytes, id } = await buildTextToRadio(to, text); const { bytes, id } = await buildTextToRadio(to, text, this.sendChannel);
const rec: MessageRecord = {
id,
dir: 'tx',
to,
text,
channel: this.sendChannel,
at: Date.now(),
status: 'pending',
};
this.pushHistory(rec);
this.pendingTx.set(id, rec);
this.socket.write(bytes); this.socket.write(bytes);
return id; return id;
} }
private pushHistory(rec: MessageRecord): void {
this.history.push(rec);
// ponytail: shift on a ≤100 array is fine; true ring buffer only if this ever gets hot
if (this.history.length > HISTORY_LIMIT) this.history.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;
if (n.user?.shortName) s.shortName = n.user.shortName;
// omit UNSET (enum value 0) rather than report "UNSET"
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
// optional proto field: undefined when the device never reported metrics
const battery = n.deviceMetrics?.batteryLevel;
if (battery !== undefined) s.batteryLevel = battery;
const p = n.position;
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
s.position = { lat: p.latitudeI * 1e-7, lon: p.longitudeI * 1e-7 };
if (p.altitude !== undefined) s.position.alt = p.altitude;
}
return s;
}
private connect(): void { private connect(): void {
if (this.shuttingDown) return; if (this.shuttingDown) return;
const socket = net.createConnection({ host: this.host, port: this.port }); const socket = net.createConnection({ host: this.host, port: this.port });
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 from the old session // fresh sync on every (re)connect — drop stale channels/nodes/history from the old session
this.channelMap = new Map(); this.channelMap = new Map();
this.nodeMap = new Map();
this.history = [];
this.pendingTx.clear();
this.logger.log(`bridge connected ${this.host}:${this.port}`); this.logger.log(`bridge connected ${this.host}:${this.port}`);
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
buildWantConfigToRadio(nonce)
.then((bytes) => socket.write(frame(bytes)))
.catch((err: unknown) =>
this.logger.error(`want_config failed: ${String(err)}`),
);
this.logger.log(`requested initial sync (want_config_id=${nonce})`);
}); });
socket.on('data', (chunk) => { socket.on('data', (chunk) => {
const merged = new Uint8Array(this.buffer.length + chunk.length); const merged = new Uint8Array(this.buffer.length + chunk.length);
@@ -122,9 +282,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs); this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectMs);
} }
private handleFromRadio( private async handleFromRadio(
fr: import('@meshtastic/protobufs').Mesh.FromRadio, fr: import('@meshtastic/protobufs').Mesh.FromRadio,
): void { ): Promise<void> {
const v = fr.payloadVariant; const v = fr.payloadVariant;
switch (v?.case) { switch (v?.case) {
case 'myInfo': case 'myInfo':
@@ -135,9 +295,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
// device streams one Channel per table slot during initial sync // device streams one Channel per table slot during initial sync
this.channelMap.set(v.value.index, v.value); this.channelMap.set(v.value.index, v.value);
break; break;
case 'nodeInfo':
// device streams the whole node DB during initial sync
this.nodeMap.set(v.value.num, v.value);
break;
case 'configCompleteId': case 'configCompleteId':
this.logger.log( this.logger.log(
`bridge ready (config complete), ${this.channelMap.size} channels`, `bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
); );
break; break;
case 'packet': { case 'packet': {
@@ -147,9 +311,54 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
? pkt.payloadVariant.value ? pkt.payloadVariant.value
: undefined; : undefined;
if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) { if (decoded && decoded.portnum === PORT_TEXT_MESSAGE_APP) {
this.logger.log( const text = new TextDecoder().decode(decoded.payload);
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`, this.logger.log(`[mesh] ${pkt.from} -> ${pkt.to}: "${text}"`);
); this.pushHistory({
id: pkt.id,
dir: 'rx',
from: pkt.from,
text,
channel: pkt.channel ?? 0,
at: Date.now(),
});
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
// live position update: the Position is serialized in Data.payload
let pos: Proto.Mesh.Position | undefined;
try {
pos = await decodePosition(decoded.payload);
} catch {
pos = undefined; // malformed payload — skip, keep whatever we had
}
if (pos?.latitudeI !== undefined && pos.longitudeI !== undefined) {
const num = pkt.from;
this.nodeMap.set(num, {
...this.nodeMap.get(num),
num,
position: pos,
});
}
} else if (decoded && decoded.portnum === PORT_ROUTING_APP) {
// ACK/NACK for our want_ack sends: Routing serialized in Data.payload,
// Data.requestId = the original packet id we sent
let routing: Proto.Mesh.Routing | undefined;
try {
routing = await decodeRouting(decoded.payload);
} catch {
routing = undefined; // malformed payload — skip
}
if (routing?.variant.case === 'errorReason' && decoded.requestId) {
const rec = this.pendingTx.get(decoded.requestId);
if (rec) {
const reason = routing.variant.value;
if (reason === 0)
rec.status = 'delivered'; // Routing_Error.NONE
else {
rec.status = 'failed';
rec.ackError = await describeRoutingError(reason);
}
this.pendingTx.delete(decoded.requestId);
}
}
} }
break; break;
} }