Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5292303786 | ||
|
|
adfbc9800b | ||
|
|
3062f40899 | ||
|
|
266aadf635 |
@@ -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; }
|
||||||
@@ -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/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
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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,8 @@ 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/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 +39,21 @@ 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/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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+2
-1
@@ -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);
|
||||||
|
|||||||
+37
-1
@@ -10,6 +10,8 @@ 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;
|
||||||
|
|
||||||
const START1 = 0x94;
|
const START1 = 0x94;
|
||||||
const START2 = 0xc3;
|
const START2 = 0xc3;
|
||||||
@@ -61,9 +63,33 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +98,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 +113,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);
|
||||||
|
}
|
||||||
|
|||||||
+103
-3
@@ -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,74 @@ 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('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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,74 @@ 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;
|
||||||
|
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 +179,261 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+130
-5
@@ -6,13 +6,36 @@ 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_TEXT_MESSAGE_APP,
|
PORT_TEXT_MESSAGE_APP,
|
||||||
buildTextToRadio,
|
buildTextToRadio,
|
||||||
|
buildWantConfigToRadio,
|
||||||
decodeFromRadio,
|
decodeFromRadio,
|
||||||
|
decodePosition,
|
||||||
extractFrames,
|
extractFrames,
|
||||||
|
frame,
|
||||||
|
loadHwModelNames,
|
||||||
} from './codec.js';
|
} from './codec.js';
|
||||||
|
|
||||||
|
/** 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 +46,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 +61,8 @@ 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>();
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
private shuttingDown = false;
|
private shuttingDown = false;
|
||||||
|
|
||||||
@@ -46,6 +76,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 +87,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 +104,96 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
this.socket.write(bytes);
|
this.socket.write(bytes);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 from the old session
|
||||||
this.channelMap = new Map();
|
this.channelMap = new Map();
|
||||||
|
this.nodeMap = new Map();
|
||||||
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 +227,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 +240,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': {
|
||||||
@@ -150,6 +259,22 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
|
`[mesh] ${pkt.from} -> ${pkt.to}: "${new TextDecoder().decode(decoded.payload)}"`,
|
||||||
);
|
);
|
||||||
|
} 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user