Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108aa400d3 | ||
|
|
f640f6658c | ||
|
|
57ad27d66f |
@@ -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 }`), `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`).
|
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | node name | "!b" | "broadcast"> }` — numbers pass through as-is (integer 1..0xffffffff), names resolve like `GET /api/nodes/:id`, omitted/""/"!b"/"broadcast" → broadcast; unknown or ambiguous targets are a 400. Returns `{ id }`), `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted), `GET /api/messages` (message history newest first: rx text with `from`, tx sends with `to` + `status` pending→delivered/failed via routing ACKs — broadcasts stay pending because firmware strips want_ack; last 100, in-memory, reset on reconnect), `GET /api/nodes` (known nodes sorted by num as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, from the initial sync plus live POSITION_APP updates; reset on reconnect) and `GET /api/nodes/:id` (lookup by exact node number/numeric string, then case-insensitive exact long/short name, else unique substring; 404 when ambiguous or missing). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. All `POST /api/send` traffic targets channel index 3 (`test`) on purpose — never the primary channel; override via `MESH_SEND_CHANNEL` (default `3`).
|
||||||
|
|
||||||
## Other
|
## Other
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.5.0 — 2026-09-24 17:37
|
||||||
|
|
||||||
|
- Publishing channel moved from `testowy` (index 2) to the new `test` channel (index 3); `MESH_SEND_CHANNEL` default is now `3`. All docs/comments referencing the old pinning updated accordingly.
|
||||||
|
|
||||||
|
## v0.4.0 — 2026-09-24 14:39
|
||||||
|
|
||||||
|
- `POST /api/send` now accepts node names and explicit broadcast sentinels in `to`: omitted/"" or `"!b"`/`"broadcast"` (case-insensitive, trimmed) → broadcast address `0xffffffff`; numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way; anything else is looked up by name via the existing node DB lookup (exact long/short name, then unique substring). Unknown or ambiguous targets are a 400 with `no node matching '<to>'`. New service method `MeshService.resolveDest(to?)` does the resolution — `sendText()` keeps its resolved-number signature.
|
||||||
|
- Swagger: `SendTextDto.to` documented as `number | string` with the full resolution order and the DM-vs-broadcast caveat (DMs are unicast wantAck-tracked via `GET /api/messages`; broadcasts stay pending because firmware strips want_ack); `@ApiOperation` notes all sends still ride channel index 2 (`testowy`) regardless of DM or broadcast.
|
||||||
|
- README + AGENTS.md endpoint lists updated for the new `to` semantics.
|
||||||
|
- Unit tests: `resolveDest` coverage on the fake bridge — omitted/""/"!b"/"BROADCAST" → broadcast, number passthrough, numeric string, exact longName match → num (node DB seeded via framed FromRadio nodeInfo), unknown name → `no node matching`, and invalid numbers (0, negative, non-integer, > 0xffffffff) rejected.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ Exposed via Traefik only (no published ports) at `http://bot.dev.kkhost.pl`.
|
|||||||
All API endpoints live under the `/api` prefix:
|
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 | node name | "!b" | "broadcast"> }`, returns `{ id }`. Numbers pass through as-is (integer in 1..0xffffffff); names resolve like `GET /api/nodes/:id` (exact long/short name, then unique substring) — unknown or ambiguous targets are a 400
|
||||||
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
- `GET /api/channels` — the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]` (psk/keys are redacted; empty until the initial sync completes)
|
||||||
|
- `GET /api/messages` — message history, newest first: inbound text (`dir:'rx'`, with `from`) and our sends (`dir:'tx'`, with `to` + `status`). tx records start `pending` and move to `delivered`/`failed` (with human-readable `ackError`) when the routing ACK/NACK arrives; broadcasts stay `pending` because firmware strips want_ack before TX. In-memory only, last 100, reset on every bridge reconnect
|
||||||
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, sorted by node number. Collected during the initial sync plus live position updates; names/hwModel/battery appear only when reported, `lastHeard` is epoch seconds (null = never heard), and `position` only when both coordinates are known
|
- `GET /api/nodes` — known mesh nodes as `{ num, longName?, shortName?, hwModel?, batteryLevel?, lastHeard: number|null, position?: { lat, lon, alt? } }[]`, 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
|
- `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
|
||||||
|
|
||||||
@@ -39,13 +40,17 @@ 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
|
||||||
curl http://localhost:3000/api/nodes/kamil
|
curl http://localhost:3000/api/nodes/kamil
|
||||||
```
|
```
|
||||||
|
|
||||||
`to` omitted, `"!b"`, or `"broadcast"` sends to the broadcast address `0xffffffff`.
|
`to` omitted, `"!b"`, or `"broadcast"` (case-insensitive) sends to the
|
||||||
|
broadcast address `0xffffffff`; anything else is a node number or name.
|
||||||
|
DMs are unicast and wantAck-tracked via `GET /api/messages`; broadcasts stay
|
||||||
|
pending because firmware strips want_ack before TX.
|
||||||
|
|
||||||
All sends always target channel index 2 (`testowy`) — never the primary
|
All sends always target channel index 3 (`test`) — never the primary
|
||||||
channel. Override with `MESH_SEND_CHANNEL`.
|
channel. Override with `MESH_SEND_CHANNEL`.
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
@@ -53,7 +58,7 @@ channel. Override with `MESH_SEND_CHANNEL`.
|
|||||||
- `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)
|
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; 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.2.0",
|
"version": "0.5.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const BROADCAST_ADDR = 0xffffffff;
|
|||||||
export const PORT_TEXT_MESSAGE_APP = 1;
|
export const PORT_TEXT_MESSAGE_APP = 1;
|
||||||
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
|
// verified against installed @meshtastic/protobufs Portnums.PortNum.POSITION_APP
|
||||||
export const PORT_POSITION_APP = 3;
|
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;
|
||||||
@@ -71,6 +73,23 @@ export async function decodePosition(
|
|||||||
return fromBinary(P.Mesh.PositionSchema, bytes);
|
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;
|
let hwModelNames: Map<number, string> | null = null;
|
||||||
/** Reverse lookup of meshtastic.HardwareModel (enum value -> name), loaded once. */
|
/** Reverse lookup of meshtastic.HardwareModel (enum value -> name), loaded once. */
|
||||||
export async function loadHwModelNames(): Promise<Map<number, string>> {
|
export async function loadHwModelNames(): Promise<Map<number, string>> {
|
||||||
|
|||||||
+44
-14
@@ -13,7 +13,6 @@ import {
|
|||||||
ApiResponse,
|
ApiResponse,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { BROADCAST_ADDR } from './codec.js';
|
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
export class SendTextDto {
|
export class SendTextDto {
|
||||||
@@ -22,7 +21,7 @@ export class SendTextDto {
|
|||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
description:
|
||||||
'Decimal node number, "!b", or "broadcast" (defaults to broadcast)',
|
'Send target. Resolution order: omitted or "" → broadcast (0xffffffff); number → used as-is (integer in 1..0xffffffff); numeric string → that node number; "!b" or "broadcast" (case-insensitive) → broadcast; anything else is looked up by name — exact long/short name, then unique substring. DMs are unicast and wantAck-tracked via GET /api/messages (pending→delivered/failed); broadcasts stay pending because firmware strips want_ack.',
|
||||||
example: '!b',
|
example: '!b',
|
||||||
})
|
})
|
||||||
to?: string | number;
|
to?: string | number;
|
||||||
@@ -58,7 +57,7 @@ export class MeshController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Send a text message over the mesh',
|
summary: 'Send a text message over the mesh',
|
||||||
description:
|
description:
|
||||||
'Queues a TEXT_MESSAGE_APP packet on the pinned test channel (index 2, "testowy" — never the primary channel). Returns the Meshtastic packet id.',
|
'Queues a TEXT_MESSAGE_APP packet to the resolved target (node number, node name, or broadcast). DMs are unicast and wantAck-tracked via GET /api/messages; broadcasts stay pending because firmware strips want_ack. All sends ride the pinned test channel (index 3, "test" — never the primary channel) regardless of DM or broadcast, since modern firmware PKI handles unicast encryption/channel anyway. Returns the Meshtastic packet id.',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -71,15 +70,13 @@ export class MeshController {
|
|||||||
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');
|
||||||
}
|
}
|
||||||
let to = BROADCAST_ADDR;
|
let to: number;
|
||||||
const t = body.to;
|
try {
|
||||||
if (t !== undefined && t !== '!b' && t !== 'broadcast') {
|
to = await this.mesh.resolveDest(body.to);
|
||||||
const n = typeof t === 'number' ? t : Number(String(t));
|
} catch (err) {
|
||||||
if (!Number.isFinite(n))
|
throw new BadRequestException(
|
||||||
throw new BadRequestException(
|
err instanceof Error ? err.message : String(err),
|
||||||
'to must be a decimal node number, "!b", or "broadcast"',
|
);
|
||||||
);
|
|
||||||
to = n;
|
|
||||||
}
|
}
|
||||||
return { id: await this.mesh.sendText(to, body.text) };
|
return { id: await this.mesh.sendText(to, body.text) };
|
||||||
}
|
}
|
||||||
@@ -96,8 +93,8 @@ export class MeshController {
|
|||||||
example: [
|
example: [
|
||||||
{ index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true },
|
{ index: 0, name: 'Default', uplinkEnabled: true, downlinkEnabled: true },
|
||||||
{
|
{
|
||||||
index: 2,
|
index: 3,
|
||||||
name: 'testowy',
|
name: 'test',
|
||||||
uplinkEnabled: false,
|
uplinkEnabled: false,
|
||||||
downlinkEnabled: false,
|
downlinkEnabled: false,
|
||||||
},
|
},
|
||||||
@@ -107,6 +104,39 @@ export class MeshController {
|
|||||||
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 test',
|
||||||
|
channel: 3,
|
||||||
|
at: 1758000123456,
|
||||||
|
status: 'delivered',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 123456,
|
||||||
|
dir: 'rx',
|
||||||
|
from: 987654321,
|
||||||
|
text: 'ping',
|
||||||
|
channel: 2,
|
||||||
|
at: 1758000000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
messages() {
|
||||||
|
return this.mesh.messages();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('nodes')
|
@Get('nodes')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Known mesh nodes (lean summaries)',
|
summary: 'Known mesh nodes (lean summaries)',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { extractFrames, frame } from './codec.js';
|
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
|
||||||
import { MeshService } from './mesh.service.js';
|
import { MeshService } from './mesh.service.js';
|
||||||
|
|
||||||
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
|
function startBridge(onSocket?: (s: net.Socket) => void): Promise<{
|
||||||
@@ -81,6 +81,63 @@ function positionPacketFrame(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KAMIL_NUM = 123456789;
|
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> {
|
function kamilNodeInfo(): Partial<P.Mesh.NodeInfo> {
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
@@ -238,11 +295,11 @@ describe('MeshService connect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('MeshService send', () => {
|
describe('MeshService send', () => {
|
||||||
it('sends on the test channel (index 2) by default, never primary', async () => {
|
it('sends on the test channel (index 3) by default, never primary', async () => {
|
||||||
const bridge = await startBridge();
|
const bridge = await startBridge();
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
delete process.env.MESH_SEND_CHANNEL; // default path: "testowy" (index 2)
|
delete process.env.MESH_SEND_CHANNEL; // default path: "test" (index 3)
|
||||||
const svc = new MeshService();
|
const svc = new MeshService();
|
||||||
try {
|
try {
|
||||||
svc.onModuleInit();
|
svc.onModuleInit();
|
||||||
@@ -252,7 +309,7 @@ describe('MeshService send', () => {
|
|||||||
|
|
||||||
const out: Uint8Array[] = [];
|
const out: Uint8Array[] = [];
|
||||||
bridge.sockets[0].on('data', (c) => out.push(c));
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
const id = await svc.sendText(42, 'hello testowy');
|
const id = await svc.sendText(42, 'hello test');
|
||||||
expect(id).toBeGreaterThan(0);
|
expect(id).toBeGreaterThan(0);
|
||||||
|
|
||||||
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
|
const readOutbound = (): P.Mesh.MeshPacket | undefined => {
|
||||||
@@ -274,7 +331,7 @@ describe('MeshService send', () => {
|
|||||||
|
|
||||||
await waitFor(() => readOutbound() !== undefined);
|
await waitFor(() => readOutbound() !== undefined);
|
||||||
const pkt = readOutbound()!;
|
const pkt = readOutbound()!;
|
||||||
expect(pkt.channel).toBe(2); // "testowy" — never the primary channel (0)
|
expect(pkt.channel).toBe(3); // "test" — never the primary channel (0)
|
||||||
expect(pkt.to).toBe(42);
|
expect(pkt.to).toBe(42);
|
||||||
const decoded =
|
const decoded =
|
||||||
pkt.payloadVariant?.case === 'decoded'
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
@@ -282,7 +339,7 @@ describe('MeshService send', () => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
expect(
|
expect(
|
||||||
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
|
new TextDecoder().decode(decoded?.payload ?? new Uint8Array()),
|
||||||
).toBe('hello testowy');
|
).toBe('hello test');
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.MESH_SEND_CHANNEL;
|
delete process.env.MESH_SEND_CHANNEL;
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
@@ -437,3 +494,179 @@ describe('MeshService node database', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 test');
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
id,
|
||||||
|
dir: 'tx',
|
||||||
|
to: 42,
|
||||||
|
text: 'hello test',
|
||||||
|
channel: 3,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MeshService resolveDest', () => {
|
||||||
|
it('resolves broadcast sentinels, numbers and names to node numbers', 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()));
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
expect(await svc.resolveDest(undefined)).toBe(BROADCAST_ADDR); // omitted → broadcast
|
||||||
|
expect(await svc.resolveDest('')).toBe(BROADCAST_ADDR); // empty → broadcast
|
||||||
|
expect(await svc.resolveDest('!b')).toBe(BROADCAST_ADDR);
|
||||||
|
expect(await svc.resolveDest('BROADCAST')).toBe(BROADCAST_ADDR); // case-insensitive
|
||||||
|
expect(await svc.resolveDest(42)).toBe(42); // number passthrough
|
||||||
|
expect(await svc.resolveDest(String(KAMIL_NUM))).toBe(KAMIL_NUM); // numeric string
|
||||||
|
expect(await svc.resolveDest('Kamil SP8MK/P')).toBe(KAMIL_NUM); // exact longName
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for unknown names and invalid node numbers', 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()));
|
||||||
|
await waitFor(async () => (await svc.nodes()).length === 1);
|
||||||
|
|
||||||
|
await expect(svc.resolveDest('zzz-nope')).rejects.toThrow(
|
||||||
|
"no node matching 'zzz-nope'",
|
||||||
|
);
|
||||||
|
for (const bad of [0, -1, 42.5, BROADCAST_ADDR + 1]) {
|
||||||
|
await expect(svc.resolveDest(bad)).rejects.toThrow(
|
||||||
|
/invalid node number/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(svc.resolveDest('0')).rejects.toThrow(/invalid node number/);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+119
-6
@@ -8,17 +8,42 @@ import {
|
|||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
import type * as Proto from '@meshtastic/protobufs';
|
import type * as Proto from '@meshtastic/protobufs';
|
||||||
import {
|
import {
|
||||||
|
BROADCAST_ADDR,
|
||||||
PORT_POSITION_APP,
|
PORT_POSITION_APP,
|
||||||
|
PORT_ROUTING_APP,
|
||||||
PORT_TEXT_MESSAGE_APP,
|
PORT_TEXT_MESSAGE_APP,
|
||||||
buildTextToRadio,
|
buildTextToRadio,
|
||||||
buildWantConfigToRadio,
|
buildWantConfigToRadio,
|
||||||
decodeFromRadio,
|
decodeFromRadio,
|
||||||
decodePosition,
|
decodePosition,
|
||||||
|
decodeRouting,
|
||||||
|
describeRoutingError,
|
||||||
extractFrames,
|
extractFrames,
|
||||||
frame,
|
frame,
|
||||||
loadHwModelNames,
|
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. */
|
/** 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'> &
|
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
||||||
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
Partial<Omit<Proto.Mesh.NodeInfo, 'num'>>;
|
||||||
@@ -46,9 +71,9 @@ 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
|
// sends are pinned to the test channel ("test", index 3) on purpose — never the primary channel
|
||||||
private readonly sendChannel = parseInt(
|
private readonly sendChannel = parseInt(
|
||||||
process.env.MESH_SEND_CHANNEL ?? '2',
|
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,6 +88,9 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
>();
|
>();
|
||||||
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
||||||
private nodeMap = new Map<number, NodeEntry>();
|
private nodeMap = new Map<number, NodeEntry>();
|
||||||
|
// 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;
|
||||||
|
|
||||||
@@ -149,15 +177,69 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a send target to a node number for sendText().
|
||||||
|
* undefined/"" and "!b"/"broadcast" (case-insensitive, trimmed) → BROADCAST_ADDR;
|
||||||
|
* numbers pass through as-is (must be an integer in 1..0xffffffff); numeric strings are parsed the same way;
|
||||||
|
* anything else is looked up via node() — throws `no node matching '<to>'` when nothing or more than one matches.
|
||||||
|
*/
|
||||||
|
async resolveDest(to?: number | string): Promise<number> {
|
||||||
|
if (typeof to === 'number') {
|
||||||
|
this.assertNodeNum(to);
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
const s = String(to ?? '').trim();
|
||||||
|
if (!s) return BROADCAST_ADDR; // omitted or empty → broadcast
|
||||||
|
if (s === '!b' || s.toLowerCase() === 'broadcast') return BROADCAST_ADDR;
|
||||||
|
if (/^\d+$/.test(s)) {
|
||||||
|
this.assertNodeNum(Number(s));
|
||||||
|
return Number(s);
|
||||||
|
}
|
||||||
|
const hit = await this.node(s);
|
||||||
|
if (!hit) throw new Error(`no node matching '${to}'`);
|
||||||
|
return hit.num;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNodeNum(n: number): void {
|
||||||
|
if (!Number.isInteger(n) || n < 1 || n > BROADCAST_ADDR)
|
||||||
|
throw new Error(`invalid node number '${n}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** 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, this.sendChannel);
|
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 {
|
private summarize(n: NodeEntry, names: Map<number, string>): NodeSummary {
|
||||||
const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null };
|
const s: NodeSummary = { num: n.num, lastHeard: n.lastHeard || null };
|
||||||
if (n.user?.longName) s.longName = n.user.longName;
|
if (n.user?.longName) s.longName = n.user.longName;
|
||||||
@@ -183,9 +265,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
// fresh sync on every (re)connect — drop stale channels/nodes 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.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;
|
const nonce = Math.floor(Math.random() * 0xffffffff) || 1;
|
||||||
buildWantConfigToRadio(nonce)
|
buildWantConfigToRadio(nonce)
|
||||||
@@ -256,9 +340,16 @@ 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) {
|
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||||
// live position update: the Position is serialized in Data.payload
|
// live position update: the Position is serialized in Data.payload
|
||||||
let pos: Proto.Mesh.Position | undefined;
|
let pos: Proto.Mesh.Position | undefined;
|
||||||
@@ -275,6 +366,28 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
position: pos,
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user