Compare commits

..
3 Commits
Author SHA1 Message Date
kamilkrzywda 3062f40899 feat: pin sends to testowy channel (index 2), never primary (v0.1.3) (#5)
CI / build + unit (push) Successful in 36s
feat: pin sends to testowy channel (index 2), never primary (v0.1.3)
2026-09-23 22:06:01 +02:00
kamilkrzywda 266aadf635 ci: Gitea Actions test pipeline (v0.1.2) (#4)
CI / build + unit (push) Successful in 40s
ci: Gitea Actions test pipeline (v0.1.2)
2026-09-23 21:53:57 +02:00
kamilkrzywda 22a209e654 fix: bridge port is 4403, not 4003 (v0.1.1) (#3)
fix: bridge port is 4403, not 4003 (v0.1.1)
2026-09-23 21:40:36 +02:00
10 changed files with 154 additions and 11 deletions
+69
View File
@@ -0,0 +1,69 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
name: build + unit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The runner provides a working docker CLI out of the box (full-dind) — no endpoint
# resolution needed, same as flight's CI.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
# Job-level dep cache via the runner's built-in cache server. continue-on-error: an
# unreachable/broken cache server must never break the run (flight convention). npm ci
# wipes node_modules, so cache npm's own tarball store instead — restoring node_modules
# would just be deleted again. No explicit save step: caches marked by the restore step
# are uploaded automatically at job end.
- name: Restore deps cache
uses: actions/cache@v4
continue-on-error: true
with:
path: $HOME/.npm
key: ${{ runner.os }}-deps-npm-${{ hashFiles('package-lock.json') }}
# Retried: the runner's outbound network/DNS is flaky (transient EAI_AGAIN seen in CI).
- name: Install dependencies
run: |
set -u
ok=0
for i in 1 2 3; do
if npm ci; then ok=1; break; fi
echo "::warning::dependency install attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::dependency install failed after 3 attempts" >&2; exit 1; }
- name: Build
run: npm run build
# vitest auto-appends results to $GITHUB_STEP_SUMMARY when GITHUB_ACTIONS=true — no
# extra summary step needed (same as flight relies on for its unit stage).
- name: Unit tests
run: npm test
# Verifies the production Dockerfile builds — the deploy path is `docker compose up -d --build`.
# Build only, nothing runs, so no cleanup/prune steps are needed (unlike flight's e2e stack).
# Retried: same flaky runner network as dep install.
- name: Docker image build
run: |
set -u
ok=0
for i in 1 2 3; do
if docker compose build --progress=plain; then ok=1; break; fi
echo "::warning::docker build attempt $i/3 failed (likely transient network) — retrying in 5s" >&2
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::docker image build failed after 3 attempts" >&2; exit 1; }
+1 -1
View File
@@ -17,7 +17,7 @@
## API ## API
1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports). 1. **Live base URL** — `http://bot.dev.kkhost.pl` (Traefik → docker-compose stack on this host; container port 3000 via external network dev_net, no published ports).
2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`), and `GET /api/channels` (the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }[]`; psk/keys redacted). Scalar API docs are served at `/` (OpenAPI JSON at `/docs-json`). Keep this list in sync with the controller. 2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: <node number | "!b" | "broadcast"> }`, returns `{ id }`), 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
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 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
- 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.
## v0.1.0 — 2026-09-23 18:54 ## v0.1.0 — 2026-09-23 18:54
- New `GET /api/channels` endpoint listing the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }`; psk/keys are redacted and never exposed over HTTP. Channels are collected from `FromRadio.channel` frames streamed during initial sync (before `config_complete_id`) and reset on every bridge reconnect so stale data is dropped after a disconnect. - New `GET /api/channels` endpoint listing the device's Meshtastic channels as `{ index, name, uplinkEnabled, downlinkEnabled }`; psk/keys are redacted and never exposed over HTTP. Channels are collected from `FromRadio.channel` frames streamed during initial sync (before `config_complete_id`) and reset on every bridge reconnect so stale data is dropped after a disconnect.
+1 -1
View File
@@ -49,7 +49,7 @@ Credentials are deliberately NOT stored in this file — see §1 for where they
## 7. App architecture quick map ## 7. App architecture quick map
- `src/mesh/codec.ts` — framing (`extractFrames`, `frame`) + protobuf encode/decode (`decodeFromRadio`, `buildTextToRadio`); unit tests in codec.spec.ts. - `src/mesh/codec.ts` — framing (`extractFrames`, `frame`) + protobuf encode/decode (`decodeFromRadio`, `buildTextToRadio`); unit tests in codec.spec.ts.
- `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4003), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `channel` → upsert into channelMap keyed by index — reset on every reconnect, `configCompleteId` → ready log, `packet` → text-message logging), fixed-interval reconnect (MESH_RECONNECT_MS, default 5 s). - `src/mesh/mesh.service.ts` — TCP connection to the bridge (MESH_HOST/MESH_PORT env, defaults 192.168.1.4:4403), buffer + frame extraction on data, FromRadio dispatch (`myInfo` → myNodeNum, `channel` → upsert into channelMap keyed by index — reset on every reconnect, `configCompleteId` → ready log, `packet` → text-message logging), fixed-interval reconnect (MESH_RECONNECT_MS, default 5 s).
- `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`, `GET /channels` (all under the `/api` global prefix set in main.ts; Scalar docs at `/`, OpenAPI JSON at `/docs-json`). - `src/mesh/mesh.controller.ts` — `GET /status`, `POST /send`, `GET /channels` (all under the `/api` global prefix set in main.ts; Scalar docs at `/`, OpenAPI JSON at `/docs-json`).
## 8. Finding current state (this file keeps no snapshots) ## 8. Finding current state (this file keeps no snapshots)
+6 -2
View File
@@ -1,7 +1,7 @@
# 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:4003`, parses inbound Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, parses inbound
protobuf frames, logs received text messages, and exposes HTTP endpoints to send text. protobuf frames, logs received text messages, and exposes HTTP endpoints to send text.
## Local run ## Local run
@@ -39,11 +39,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 `4003`) — bridge port - `MESH_PORT` (default `4403`) — bridge port
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms - `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
- `MESH_SEND_CHANNEL` (default `2`) — channel index all sends target (`testowy`; never the primary channel)
- `PORT` (default `3000`) — HTTP listen port - `PORT` (default `3000`) — HTTP listen port
Note: the serial bridge accepts only ONE client at a time — if another app is Note: the serial bridge accepts only ONE client at a time — if another app is
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build: . build: .
environment: environment:
MESH_HOST: 192.168.1.4 MESH_HOST: 192.168.1.4
MESH_PORT: '4003' MESH_PORT: '4403'
restart: unless-stopped restart: unless-stopped
networks: networks:
- dev_net - dev_net
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mesh-bot", "name": "mesh-bot",
"version": "0.1.0", "version": "0.1.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2 -1
View File
@@ -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: {
+54 -2
View File
@@ -1,9 +1,9 @@
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(): Promise<{
@@ -116,3 +116,55 @@ describe('MeshService channel pipeline', () => {
} }
}); });
}); });
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);
if (!packets.length) return undefined;
const tr = fromBinary(P.Mesh.ToRadioSchema, packets[0]);
return tr.payloadVariant?.case === 'packet'
? tr.payloadVariant.value
: 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();
}
});
});
+7 -2
View File
@@ -17,12 +17,17 @@ import {
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);
private readonly host = process.env.MESH_HOST ?? '192.168.1.4'; private readonly host = process.env.MESH_HOST ?? '192.168.1.4';
private readonly port = parseInt(process.env.MESH_PORT ?? '4003', 10); private readonly port = parseInt(process.env.MESH_PORT ?? '4403', 10);
// ponytail: fixed-interval retry, no backoff — fine for single-bridge use // ponytail: fixed-interval retry, no backoff — fine for single-bridge use
private readonly reconnectMs = parseInt( private readonly reconnectMs = parseInt(
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 +80,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;
} }