From 95fab10f82bb19545fee90b490e0be16ffa61383 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 23 Sep 2026 20:36:14 +0000 Subject: [PATCH] Send want_config_id on connect to trigger initial sync --- CHANGELOG.md | 4 ++ README.md | 6 ++- package.json | 2 +- src/mesh/codec.ts | 10 +++++ src/mesh/mesh.service.spec.ts | 76 +++++++++++++++++++++++++++++++---- src/mesh/mesh.service.ts | 9 +++++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ba533..ee6c384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/README.md b/README.md index 5502db0..6d9b799 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # mesh-bot 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 -protobuf frames, logs received text messages, and exposes HTTP endpoints to send text. +Meshtastic serial bridge (raw socat pipe) at `192.168.1.4:4403`, sends +`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 diff --git a/package.json b/package.json index bb24604..2234d9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.1.3", + "version": "0.1.4", "description": "", "author": "", "private": true, diff --git a/src/mesh/codec.ts b/src/mesh/codec.ts index a0935c3..cc3689f 100644 --- a/src/mesh/codec.ts +++ b/src/mesh/codec.ts @@ -88,3 +88,13 @@ export async function buildTextToRadio( }); return { bytes: frame(toBinary(P.Mesh.ToRadioSchema, toRadio)), id }; } + +export async function buildWantConfigToRadio( + wantConfigId: number, +): Promise { + const P = await loadProto(); + const toRadio = create(P.Mesh.ToRadioSchema, { + payloadVariant: { case: 'wantConfigId', value: wantConfigId }, + }); + return toBinary(P.Mesh.ToRadioSchema, toRadio); +} diff --git a/src/mesh/mesh.service.spec.ts b/src/mesh/mesh.service.spec.ts index 3a51a41..851b060 100644 --- a/src/mesh/mesh.service.spec.ts +++ b/src/mesh/mesh.service.spec.ts @@ -6,13 +6,16 @@ import { describe, expect, it } from 'vitest'; import { extractFrames, frame } from './codec.js'; import { MeshService } from './mesh.service.js'; -function startBridge(): Promise<{ +function startBridge(onSocket?: (s: net.Socket) => void): Promise<{ port: number; sockets: net.Socket[]; close: () => void; }> { 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) => { server.listen(0, '127.0.0.1', () => { resolve({ @@ -117,6 +120,63 @@ 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(); @@ -143,11 +203,13 @@ describe('MeshService send', () => { 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; + // 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); diff --git a/src/mesh/mesh.service.ts b/src/mesh/mesh.service.ts index d84a597..52fb390 100644 --- a/src/mesh/mesh.service.ts +++ b/src/mesh/mesh.service.ts @@ -9,8 +9,10 @@ import * as net from 'node:net'; import { PORT_TEXT_MESSAGE_APP, buildTextToRadio, + buildWantConfigToRadio, decodeFromRadio, extractFrames, + frame, } from './codec.js'; @Injectable() @@ -94,6 +96,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy { // fresh sync on every (re)connect — drop stale channels from the old session this.channelMap = new Map(); 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) => { const merged = new Uint8Array(this.buffer.length + chunk.length); -- 2.54.0