From 3062f4089983c0945e10645fbb4b1f0116b92165 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 22:06:01 +0200 Subject: [PATCH] feat: pin sends to testowy channel (index 2), never primary (v0.1.3) (#5) feat: pin sends to testowy channel (index 2), never primary (v0.1.3) --- AGENTS.md | 2 +- CHANGELOG.md | 4 +++ README.md | 4 +++ package.json | 2 +- src/mesh/codec.ts | 3 +- src/mesh/mesh.service.spec.ts | 56 +++++++++++++++++++++++++++++++++-- src/mesh/mesh.service.ts | 7 ++++- 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6189d85..53cf6da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## 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). -2. **Endpoints** — all under the `/api` global prefix: `GET /api/status` (`{ connected, myNodeNum, host, port }`), `POST /api/send` (body `{ "text": "...", "to"?: }`, 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"?: }`, 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 82da04e..61ba533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/README.md b/README.md index 063b292..5502db0 100644 --- a/README.md +++ b/README.md @@ -39,11 +39,15 @@ curl http://localhost:3000/api/channels `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 - `MESH_HOST` (default `192.168.1.4`) — bridge host - `MESH_PORT` (default `4403`) — bridge port - `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 Note: the serial bridge accepts only ONE client at a time — if another app is diff --git a/package.json b/package.json index 7799886..bb24604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.1.2", + "version": "0.1.3", "description": "", "author": "", "private": true, diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index f1787dc..a0935c3 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -64,6 +64,7 @@ export async function decodeFromRadio( export async function buildTextToRadio( to: number, text: string, + channelIndex: number, ): Promise<{ bytes: Uint8Array; id: number }> { const P = await loadProto(); let id = Math.floor(Math.random() * 0x100000000) >>> 0; @@ -72,7 +73,7 @@ export async function buildTextToRadio( to, id, wantAck: true, - channel: 0, + channel: channelIndex, payloadVariant: { case: 'decoded', value: { diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index a0213c9..3a51a41 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -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 net from 'node:net'; import type { AddressInfo } from 'node:net'; import { describe, expect, it } from 'vitest'; -import { frame } from './codec.js'; +import { extractFrames, frame } from './codec.js'; import { MeshService } from './mesh.service.js'; 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(); + } + }); +}); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index 3641fe8..d84a597 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -23,6 +23,11 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { process.env.MESH_RECONNECT_MS ?? '5000', 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 buffer: Uint8Array = new Uint8Array(0); @@ -75,7 +80,7 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { async sendText(to: number, text: string): Promise { if (!this.connected || !this.socket) 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); return id; }