From 3b1f5de101657e22c31ab40453a4495fdad641ba Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 20:58:43 +0000 Subject: [PATCH] =?UTF-8?q?Add=20#loser=20command=20=E2=80=94=20worst=20pa?= =?UTF-8?q?cket=20delivery=20record=20over=20directed=20sends=20(v0.21.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CHANGELOG.md | 5 ++ package.json | 2 +- src/commands/command.service.spec.ts | 126 ++++++++++++++++++++++++++- src/commands/command.service.ts | 50 +++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2ebea3..5d27eab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,5 +27,5 @@ - MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages. - Hourly cron weather report job (`src/weather`): fetches Open-Meteo current conditions and broadcasts a one-line report to the mesh on the pinned send channel; env `WEATHER_CRON`/`WEATHER_LAT`/`WEATHER_LON`/`WEATHER_CITY`. -- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns a reply string — or an array of lines, sent as one message per line through the same routing, spaced `MESH_MULTI_SEND_DELAY_MS` ms apart (default 5000) so each packet has time to land in RF — or void = no reply). First commands: `#ping` → replies `#pong!`; `#help` → short single-message list of all available commands. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add ` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `: ` with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop `fitOneLine()` hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 2500 summary headroom). +- Channel-2 auto-responder (`src/commands`): event-driven — `CommandService.onRx()` is called by `MeshService` for every decoded rx text (right after the notify gate) and dispatches exact, case-sensitive `#command` matches on `COMMAND_CHANNEL` (env, default 2) through a single-registration-point registry (handlers registered in the constructor via `register(name, fn)`; handler gets `{ text, from?, id? }`, returns a reply string — or an array of lines, sent as one message per line through the same routing, spaced `MESH_MULTI_SEND_DELAY_MS` ms apart (default 5000) so each packet has time to land in RF — or void = no reply). First commands: `#ping` → replies `#pong!`; `#loser` → worst packet delivery record over directed sends only (groups our own tx history by destination, counts delivered vs failed per node; broadcasts excluded — firmware strips want_ack so they stay pending and the broadcast addr is never a real destination; loser = most failures, tie-break worse failure ratio; destination resolved to long/short name with `@!xxxxxxxx` mention fallback when unknown; three reply shapes: `worst delivery: (@!hex) - N failed / M delivered`, "no failed sends yet", or "not enough data yet" explaining only directed sends count); `#help` → short single-message list of all available commands. Replies go through `MeshService.respond(id, reply)`: auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination), riding the original record's channel with `replyId = id` so the recipient app renders it as a threaded quote — `COMMAND_CHANNEL` is only the trigger/listen gate. Only when the trigger has no packet id does it fall back to legacy sendText routing on `COMMAND_CHANNEL`. A failing handler/respond only logs a warn. DI: provided by `CommandModule`'s factory (injects `MeshService`, attaches itself via `attachCommands()`) — no constructor-level cycle; `forwardRef` was tried and left one side null in NestJS 12's injector. Prefix dispatch (`registerPrefix(name, fn)`) lets one handler own a whole family: the `#rss` subcommands live in `src/rss` (`RssService.handle`, registered as prefix `'rss'` from the CommandModule factory — exact matches still win over prefixes). Subcommands: `#rss help`; `#rss add ` (validates URL, fetches with 15 s timeout, requires ≥1 titled item, rejects duplicate names case-insensitively; LLM generates 2-4 lowercase tags from up to 8 latest titles — on LLM failure the source is added with empty tags and the reply says so); `#rss remove `; `#rss tags` (all distinct tags); `#rss sources`; `#rss get ` (LLM headline summary as 3-5 SEPARATE mesh messages: prompt asks for exactly 3-5 lines, one item per line, `: ` with ≤20-char header, no intro/outro/numbering; reply parsed into items — drop empty/separator-less lines, cap at 5, fewer than 3 → send what we got (no retry), zero → no message; each item sent via its own respond() threaded to the trigger packet id, per-line backstop `fitOneLine()` hard-cuts >185-char lines at a word boundary near 180 so every item fits one packet; maxTokens 2500 is headroom because the LLM server's reasoning tokens count against it — see rss.service.ts comment; `#tag` collects items across ALL sources carrying that tag — one dead feed does not sink the summary). Storage: `rss_sources(name TEXT PRIMARY KEY, url, tags, created_at)` table in the EXISTING MESH_DB_PATH file via a second node:sqlite connection (names matched case-insensitively); feed parsing via `fast-xml-parser` (RSS 2.0 + Atom; malformed/no-items → "not a valid feed"); LLM via injected `LlmService.chat()` with explicit maxTokens (50 tags / 2500 summary headroom). - LLM client (`src/llm`): `LlmService` wraps the local OpenAI-compatible server (default `http://192.168.1.70:1111/v1`, no auth) with native fetch — plain feature module, no Mesh/Schedule dependency. Methods: `listModels()` → `data[].id`; `chat(messages, { maxTokens?, temperature? })` → `choices[0].message.content` (non-2xx throws with status + body snippet); `summarize(text)` = chat() with system prompt "Summarize the following text concisely." Env: `LLM_API_BASE`, `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default 180000, via `AbortSignal.timeout`). diff --git a/CHANGELOG.md b/CHANGELOG.md index d832a4a..65dd2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.21.9 — 2026-09-29 20:56 + +- New `#loser` command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to `longName`/`shortName`, falling back to the `@!xxxxxxxx` mention when unknown. Three reply shapes: worst delivery line (`worst delivery: (@!hex) - N failed / M delivered`), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to `#ping`; listed in `#help`. +- Tests: 175 → 180 — new `#loser` specs cover all three reply cases, broadcast/pending exclusion, the unknown-node `@!hex` fallback, and the failure-ratio tie-break; the `#help` spec now also asserts `#loser` is listed. + ## v0.21.8 — 2026-09-27 07:13 - Fixed `npm test` exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit `read ECONNRESET`. Those test-owned sockets had no `'error'` handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. `startBridge()` now attaches a no-op `'error'` handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — `fake-bridge.ts` is imported by specs only; no runtime diff, no deploy needed. diff --git a/package.json b/package.json index ca3d6ae..cca492d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mesh-bot", - "version": "0.21.8", + "version": "0.21.9", "description": "", "author": "", "private": true, diff --git a/src/commands/command.service.spec.ts b/src/commands/command.service.spec.ts index 948f553..09c443d 100644 --- a/src/commands/command.service.spec.ts +++ b/src/commands/command.service.spec.ts @@ -13,7 +13,11 @@ import { KAMIL_NUM, } from '../mesh/fake-bridge.js'; import { BROADCAST_ADDR } from '../mesh/codec.js'; -import type { MeshService } from '../mesh/mesh.service.js'; +import type { + MessageRecord, + MeshService, + NodeSummary, +} from '../mesh/mesh.service.js'; import { MeshService as RealMeshService } from '../mesh/mesh.service.js'; import { CommandService } from './command.service.js'; @@ -47,6 +51,7 @@ describe('CommandService.onRx', () => { expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line const text = mesh.respond.mock.calls[0][1]; expect(text).toContain('#ping'); + expect(text).toContain('#loser'); expect(text).toContain('#rss'); }); @@ -205,6 +210,125 @@ describe('CommandService.onRx', () => { }); }); +describe('#loser', () => { + const tx = ( + id: number, + to: number, + status: MessageRecord['status'], + ): MessageRecord => ({ + id, + dir: 'tx', + to, + text: 'hi', + channel: 3, + at: Date.now(), + status, + }); + + const makeLoserMesh = ( + messages: MessageRecord[], + nodes: NodeSummary[], + ): [CommandService, MeshService] => { + const mesh = { + sendText: vi.fn(), + respond: vi.fn().mockResolvedValue({ id: 1 }), + messages: () => messages, + nodes: async () => nodes, + } as unknown as MeshService; + return [new CommandService(mesh), mesh]; + }; + + it('reports the node with the most failures — named via longName, broadcasts and pending sends excluded', async () => { + const messages = [ + tx(10, BROADCAST_ADDR, 'failed'), // firmware quirk: a broadcast NACK is never a real destination + tx(9, 222, 'failed'), // Alice: 3 failed / 0 delivered → loser + tx(8, 111, 'delivered'), // Bob: 1 failed / 2 delivered + tx(7, 222, 'failed'), + tx(6, 111, 'failed'), + tx(5, 333, 'pending'), // no final status yet — ignored + tx(4, 222, 'failed'), + { + id: 3, + dir: 'rx', + from: 999, + text: 'hello', + channel: 0, + at: Date.now(), + }, + ]; + const nodes = [ + { num: 111, shortName: 'Bob', lastHeard: null }, + { num: 222, longName: 'Alice', lastHeard: null }, + ]; + const [svc, mesh] = makeLoserMesh(messages, nodes); + + await svc.onRx(2, '#loser', 42, 7); + + expect(mesh.respond).toHaveBeenCalledWith( + 7, + 'worst delivery: Alice (@!000000de) - 3 failed / 0 delivered', + ); + expect(mesh.sendText).not.toHaveBeenCalled(); + }); + + it('says no failed sends when the directed history is all delivered', async () => { + const messages = [tx(2, 5, 'delivered'), tx(1, 6, 'delivered')]; + const [svc, mesh] = makeLoserMesh(messages, []); + + await svc.onRx(2, '#loser', 42, 7); + + expect(mesh.respond).toHaveBeenCalledWith( + 7, + 'no failed sends yet - everyone delivers :)', + ); + }); + + it('explains the directed-only rule when there is no final-status data at all', async () => { + const messages = [tx(2, BROADCAST_ADDR, 'pending'), tx(1, 5, 'pending')]; + const [svc, mesh] = makeLoserMesh(messages, []); + + await svc.onRx(2, '#loser', 42, 7); + + expect(mesh.respond).toHaveBeenCalledWith( + 7, + 'not enough data yet - only directed sends count (broadcasts have no acks)', + ); + }); + + it('falls back to the @!hex mention when the worst node has no known name', async () => { + const messages = [tx(1, 600, 'failed')]; + const nodes = [{ num: 5, longName: 'Someone Else', lastHeard: null }]; + const [svc, mesh] = makeLoserMesh(messages, nodes); + + await svc.onRx(2, '#loser', 42, 7); + + expect(mesh.respond).toHaveBeenCalledWith( + 7, + 'worst delivery: @!00000258 - 1 failed / 0 delivered', + ); + }); + + it('tie-breaks equal failure counts by the worse failure ratio', async () => { + const messages = [ + // node 7: 1 failed / 9 delivered (ratio 0.1) — not the loser + tx(16, 8, 'failed'), // node 8: 1 failed / 1 delivered (ratio 0.5) → loser + tx(15, 8, 'delivered'), + tx(14, 7, 'failed'), + ...Array.from({ length: 9 }, (_, i) => + tx(i + 1, 7, 'delivered' as const), + ), + ]; + const [svc, mesh] = makeLoserMesh(messages, []); + + await svc.onRx(2, '#loser', 42, 7); + + expect(mesh.respond).toHaveBeenCalledWith( + 7, + 'worst delivery: @!00000008 - 1 failed / 1 delivered', + ); + }); +}); + describe('#rss prefix dispatch', () => { let dir: string | undefined; diff --git a/src/commands/command.service.ts b/src/commands/command.service.ts index c0a4314..63b8448 100644 --- a/src/commands/command.service.ts +++ b/src/commands/command.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; +// pure formatting helper (no cycle: battery → mesh only) +import { mention } from '../battery/battery.service.js'; import { BROADCAST_ADDR } from '../mesh/codec.js'; // type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss import type { MeshService } from '../mesh/mesh.service.js'; @@ -43,10 +45,12 @@ export class CommandService { rss?: RssService, ) { this.register('ping', () => '#pong!'); + this.register('loser', () => this.loserReply()); // single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line this.register('help', () => [ '#ping - alive check', + '#loser - worst delivery record', '#rss help - rss subcommands', '#rss add - add feed with AI tags', '#rss remove - drop feed', @@ -57,6 +61,52 @@ export class CommandService { if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx)); } + /** + * #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide + * loss stats, so the only real data is our own tx history: group by destination, count delivered vs + * failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and + * the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most + * failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars. + */ + private async loserReply(): Promise { + const mesh = this.mesh; + if (!mesh) + return 'not enough data yet - only directed sends count (broadcasts have no acks)'; + const stats = new Map(); + for (const m of mesh.messages()) { + if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR) + continue; // directed sends only + if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet + const s = stats.get(m.to) ?? { failed: 0, delivered: 0 }; + if (m.status === 'failed') s.failed += 1; + else s.delivered += 1; + stats.set(m.to, s); + } + if (stats.size === 0) + return 'not enough data yet - only directed sends count (broadcasts have no acks)'; + let worst: { num: number; failed: number; total: number } | undefined; + for (const [num, s] of stats) { + if (s.failed === 0) continue; // zero-failure nodes can't be the loser + const total = s.failed + s.delivered; + // most failures first, then worse failure ratio on a tie + if ( + !worst || + s.failed > worst.failed || + (s.failed === worst.failed && + s.failed / total > worst.failed / worst.total) + ) { + worst = { num, failed: s.failed, total }; + } + } + if (!worst) return 'no failed sends yet - everyone delivers :)'; + const names = new Map( + (await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]), + ); + const name = names.get(worst.num); + const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num); + return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`; + } + /** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */ register(name: string, fn: CommandHandler): void { this.commands.set(name, fn); -- 2.54.0