Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }`), 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. 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,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
@@ -39,11 +41,15 @@ curl http://localhost:3000/api/channels
|
|||||||
|
|
||||||
`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.1.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+12
-1
@@ -64,6 +64,7 @@ export async function decodeFromRadio(
|
|||||||
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 +73,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 +88,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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -116,3 +119,114 @@ 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import * as net from 'node:net';
|
|||||||
import {
|
import {
|
||||||
PORT_TEXT_MESSAGE_APP,
|
PORT_TEXT_MESSAGE_APP,
|
||||||
buildTextToRadio,
|
buildTextToRadio,
|
||||||
|
buildWantConfigToRadio,
|
||||||
decodeFromRadio,
|
decodeFromRadio,
|
||||||
extractFrames,
|
extractFrames,
|
||||||
|
frame,
|
||||||
} from './codec.js';
|
} from './codec.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -23,6 +25,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);
|
||||||
@@ -75,7 +82,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -89,6 +96,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
// fresh sync on every (re)connect — drop stale channels from the old session
|
// fresh sync on every (re)connect — drop stale channels from the old session
|
||||||
this.channelMap = new Map();
|
this.channelMap = 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user