Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023cd3eef3 | ||
|
|
c5a2815c01 | ||
|
|
55e59d4963 | ||
|
|
9d4c9594af |
@@ -27,4 +27,5 @@
|
|||||||
|
|
||||||
- MESHTASTIC.md — rules for Meshtastic communication (owner authority, language, response protocol); read before acting on mesh messages.
|
- 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`.
|
- 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 reply string or void = no reply). First command: `#ping` → replies `#pong!`. Replies ride `COMMAND_CHANNEL`: directed DM back to the sender when present, else broadcast. A failing handler/send 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.
|
- 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 reply string or void = no reply). First command: `#ping` → replies `#pong!`. 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.
|
||||||
|
- 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 60000, via `AbortSignal.timeout`).
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.0 — 2026-09-25 18:03
|
||||||
|
|
||||||
|
- New LLM client module (`src/llm`): `LlmService` speaks the OpenAI-compatible chat-completions API of the local server at `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`, no auth) using native fetch only — no new dependencies. Plain feature module (`LlmModule`), wired into `app.module.ts`; no Mesh/Schedule dependency.
|
||||||
|
- Methods: `listModels()` (GET `/models` → `data[].id` strings), `chat(messages, opts?)` (POST `/chat/completions` with `{ model, messages, ...opts }` → `choices[0].message.content`; non-2xx throws an Error carrying the HTTP status and a short body snippet; malformed responses without content throw too), and `summarize(text)` — thin wrapper over `chat()` with system prompt "Summarize the following text concisely." (the stated primary use).
|
||||||
|
- Env: `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`), `LLM_MODEL` (default `qwen3.8-27b`), `LLM_TIMEOUT_MS` (default `60000` — the local 27B model can be slow; applied via `AbortSignal.timeout`).
|
||||||
|
- Tests: +4 specs — listModels parses ids, chat passes model/messages/opts through and returns content, chat throws on non-2xx with status in the message, summarize sends system prompt + user text.
|
||||||
|
|
||||||
|
## v0.18.1 — 2026-09-25 17:30
|
||||||
|
|
||||||
|
- Command replies now ride `MeshService.respond(id, reply)` instead of direct `sendText` routing: when a handler returns a non-empty string and the trigger has a packet id, the reply is auto-routed (rx → DM back to the original sender unless the original was a broadcast; tx → its destination) on the **original message's channel** with `replyId = id`, so the recipient app renders it as a threaded quote of the triggering packet. `COMMAND_CHANNEL` is now only the trigger/listen gate — the reply channel comes from the record.
|
||||||
|
- Defensive fallback: when the trigger has no packet id (never happens in production — `MeshService` always passes `pkt.id`), the legacy sendText routing on `COMMAND_CHANNEL` (directed DM or broadcast) still sends the reply out. Failure handling unchanged: a failing handler or respond only logs a warn, never crashes.
|
||||||
|
- Tests: 121 → 124 specs — unit cases now mock `mesh.respond` (id present → `respond(id, reply)` with no sendText; id absent → legacy DM/broadcast fallback on the command channel), plus a failing-respond swallow case; e2e over the fake bridge asserts wire-level threading for both shapes: `#ping` broadcast on ch2 → `#pong!` broadcast on ch2 with `replyId` = ping packet id, and `#ping` DM → `#pong!` DM to the sender on ch2 with `replyId` set. All gate tests (case/whitespace/channel/env override) kept.
|
||||||
|
- The wire-decode test helper `outboundPackets()` moved from `mesh.service.spec.ts` into `fake-bridge.ts` so both specs share it.
|
||||||
|
- Docs: AGENTS.md "Other" bullet and MESHTASTIC.md describe the respond() auto-routing + threading; README env list unchanged (`COMMAND_CHANNEL` still exists, now meaning trigger channel only).
|
||||||
|
|
||||||
## v0.18.0 — 2026-09-25 17:08
|
## v0.18.0 — 2026-09-25 17:08
|
||||||
|
|
||||||
- New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`.
|
- New channel-index-2 auto-responder (`src/commands`): an event-driven `CommandService` reacts to decoded rx text on `COMMAND_CHANNEL` (env, default `2`) and dispatches exact `#command` matches (case-sensitive, surrounding whitespace trimmed) through a single-registration-point registry — handlers registered in the constructor via `register(name, fn)` receive `{ text, from?, id? }` and return a reply string or void (= no reply). First command: `#ping` → replies `#pong!`.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ Rules for how mesh-bot communicates over Meshtastic and how the agent should rea
|
|||||||
|
|
||||||
- All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel.
|
- All bot sends ride channel index 3 ("test") by design (`MESH_SEND_CHANNEL`, default "3") — never the primary channel.
|
||||||
- Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted.
|
- Broadcasts show up in `/api/messages` as `failed` / `NO_RESPONSE` on this firmware — known quirk, not a real failure; the message was transmitted.
|
||||||
- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) get an automatic reply on that channel — directed back to the sender when present, else broadcast. Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything.
|
- Channel index 2 carries the bot's auto-responder (`COMMAND_CHANNEL`, default 2): exact `#command` texts (case-sensitive) on that channel get an automatic reply routed through `MeshService.respond()` — DM back to the original sender, broadcast when the original was a broadcast — riding the original message's channel as a threaded quote (`replyId` = the trigger packet id). Currently only `#ping` → `#pong!`. This is a mechanical echo for testing the mesh path; it is not the agent and does not mean the owner has seen anything.
|
||||||
|
|
||||||
## Notifications (mesh → agent)
|
## Notifications (mesh → agent)
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
|
|||||||
- `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation)
|
- `MESH_DB_PATH` (default `./data/mesh-bot.db`) — SQLite file backing the message history (parent dir created if missing; in Docker, mounted at `/app/data` via a named volume so it survives container recreation)
|
||||||
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
|
- `MESH_POLL_MS` (default `1000`) — how often the poller re-upserts the message history into SQLite and prunes rows beyond the newest 1000
|
||||||
- `PORT` (default `3000`) — HTTP listen port
|
- `PORT` (default `3000`) — HTTP listen port
|
||||||
|
- `LLM_API_BASE` (default `http://192.168.1.70:1111/v1`) — OpenAI-compatible LLM server base URL used by the in-process `LlmService` (`src/llm`; no auth)
|
||||||
|
- `LLM_MODEL` (default `qwen3.8-27b`) — model id sent to `/chat/completions`
|
||||||
|
- `LLM_TIMEOUT_MS` (default `60000`) — per-request timeout for LLM calls (the local 27B model can be slow)
|
||||||
|
|
||||||
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
|
||||||
connected to the radio, this bot will not get a connection.
|
connected to the radio, this bot will not get a connection.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+8
-1
@@ -1,10 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { BatteryAlertModule } from './battery/battery.module.js';
|
import { BatteryAlertModule } from './battery/battery.module.js';
|
||||||
import { CommandModule } from './commands/command.module.js';
|
import { CommandModule } from './commands/command.module.js';
|
||||||
|
import { LlmModule } from './llm/llm.module.js';
|
||||||
import { MeshModule } from './mesh/mesh.module.js';
|
import { MeshModule } from './mesh/mesh.module.js';
|
||||||
import { WeatherModule } from './weather/weather.module.js';
|
import { WeatherModule } from './weather/weather.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MeshModule, WeatherModule, BatteryAlertModule, CommandModule],
|
imports: [
|
||||||
|
MeshModule,
|
||||||
|
WeatherModule,
|
||||||
|
BatteryAlertModule,
|
||||||
|
CommandModule,
|
||||||
|
LlmModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
startBridge,
|
startBridge,
|
||||||
textPacketFrame,
|
textPacketFrame,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
outboundPackets,
|
||||||
KAMIL_NUM,
|
KAMIL_NUM,
|
||||||
} from '../mesh/fake-bridge.js';
|
} from '../mesh/fake-bridge.js';
|
||||||
import type { MeshService } from '../mesh/mesh.service.js';
|
import type { MeshService } from '../mesh/mesh.service.js';
|
||||||
@@ -12,8 +13,12 @@ import { CommandService } from './command.service.js';
|
|||||||
|
|
||||||
function makeService(
|
function makeService(
|
||||||
sendText?: ReturnType<typeof vi.fn>,
|
sendText?: ReturnType<typeof vi.fn>,
|
||||||
|
respond?: ReturnType<typeof vi.fn>,
|
||||||
): [CommandService, MeshService] {
|
): [CommandService, MeshService] {
|
||||||
const mesh = { sendText: sendText ?? vi.fn() } as unknown as MeshService;
|
const mesh = {
|
||||||
|
sendText: sendText ?? vi.fn(),
|
||||||
|
respond: respond ?? vi.fn().mockResolvedValue({ id: 1 }),
|
||||||
|
} as unknown as MeshService;
|
||||||
return [new CommandService(mesh), mesh];
|
return [new CommandService(mesh), mesh];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,15 +27,24 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('CommandService.onRx', () => {
|
describe('CommandService.onRx', () => {
|
||||||
it('answers #ping on the command channel with a directed DM back to the sender', async () => {
|
it('replies #ping via respond() — threaded quote of the trigger packet, no direct sendText', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, '#ping', 42, 7);
|
await svc.onRx(2, '#ping', 42, 7);
|
||||||
|
expect(mesh.respond).toHaveBeenCalledWith(7, '#pong!');
|
||||||
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
|
||||||
|
const [svc, mesh] = makeService();
|
||||||
|
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('broadcasts the reply when there is no sender', async () => {
|
it('falls back to a broadcast sendText on COMMAND_CHANNEL when the trigger has no packet id (no sender)', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, '#ping');
|
await svc.onRx(2, '#ping'); // no from, no id — defensive path
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).toHaveBeenCalledWith(
|
expect(mesh.sendText).toHaveBeenCalledWith(
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
'#pong!',
|
'#pong!',
|
||||||
@@ -43,6 +57,7 @@ describe('CommandService.onRx', () => {
|
|||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(0, '#ping', 42);
|
await svc.onRx(0, '#ping', 42);
|
||||||
await svc.onRx(3, '#ping', 42);
|
await svc.onRx(3, '#ping', 42);
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +65,7 @@ describe('CommandService.onRx', () => {
|
|||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, 'hello there', 42);
|
await svc.onRx(2, 'hello there', 42);
|
||||||
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
|
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,28 +73,31 @@ describe('CommandService.onRx', () => {
|
|||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, '#Ping', 42);
|
await svc.onRx(2, '#Ping', 42);
|
||||||
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
|
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims surrounding whitespace before matching', async () => {
|
it('trims surrounding whitespace before matching', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, ' #ping ', 42);
|
await svc.onRx(2, ' #ping ', 42, 9);
|
||||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
|
expect(mesh.respond).toHaveBeenCalledWith(9, '#pong!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('honors the COMMAND_CHANNEL env override', async () => {
|
it('honors the COMMAND_CHANNEL env override', async () => {
|
||||||
process.env.COMMAND_CHANNEL = '5';
|
process.env.COMMAND_CHANNEL = '5';
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
await svc.onRx(2, '#ping', 42); // old default no longer triggers
|
await svc.onRx(2, '#ping', 42); // old default no longer triggers
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
await svc.onRx(5, '#ping', 42);
|
await svc.onRx(5, '#ping', 42, 8);
|
||||||
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 5);
|
expect(mesh.respond).toHaveBeenCalledWith(8, '#pong!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends nothing when a handler returns void', async () => {
|
it('sends nothing when a handler returns void', async () => {
|
||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
svc.register('silent', () => undefined);
|
svc.register('silent', () => undefined);
|
||||||
await svc.onRx(2, '#silent', 42);
|
await svc.onRx(2, '#silent', 42);
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +105,7 @@ describe('CommandService.onRx', () => {
|
|||||||
const [svc, mesh] = makeService();
|
const [svc, mesh] = makeService();
|
||||||
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
|
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
|
||||||
await svc.onRx(2, '#status', 99, 5);
|
await svc.onRx(2, '#status', 99, 5);
|
||||||
expect(mesh.sendText).toHaveBeenCalledWith(99, 'up from 99', undefined, 2);
|
expect(mesh.respond).toHaveBeenCalledWith(5, 'up from 99');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
it('swallows a failing handler (a bad command must not crash the app)', async () => {
|
||||||
@@ -95,19 +114,28 @@ describe('CommandService.onRx', () => {
|
|||||||
throw new Error('kaboom');
|
throw new Error('kaboom');
|
||||||
});
|
});
|
||||||
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
|
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
|
||||||
|
expect(mesh.respond).not.toHaveBeenCalled();
|
||||||
expect(mesh.sendText).not.toHaveBeenCalled();
|
expect(mesh.sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('swallows a failing respond (unknown id / bridge down) without throwing', async () => {
|
||||||
|
const [svc] = makeService(
|
||||||
|
undefined,
|
||||||
|
vi.fn().mockRejectedValue(new Error('no message with id 7')),
|
||||||
|
);
|
||||||
|
await expect(svc.onRx(2, '#ping', 42, 7)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('swallows a failing send (mesh down) without throwing', async () => {
|
it('swallows a failing send (mesh down) without throwing', async () => {
|
||||||
const [svc] = makeService(
|
const [svc] = makeService(
|
||||||
vi.fn().mockRejectedValue(new Error('no bridge')),
|
vi.fn().mockRejectedValue(new Error('no bridge')),
|
||||||
);
|
);
|
||||||
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined();
|
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined(); // no id → legacy path
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MeshService wiring (event-driven trigger)', () => {
|
describe('MeshService wiring (event-driven trigger)', () => {
|
||||||
it('calls onRx for decoded rx text and answers #ping over the wire', async () => {
|
it('answers a #ping DM on ch2 with a directed #pong! back to the sender, same channel, threaded quote', async () => {
|
||||||
const bridge = await startBridge();
|
const bridge = await startBridge();
|
||||||
process.env.MESH_HOST = '127.0.0.1';
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
process.env.MESH_PORT = String(bridge.port);
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
@@ -120,6 +148,10 @@ describe('MeshService wiring (event-driven trigger)', () => {
|
|||||||
() => bridge.sockets.length === 1 && svc.status().connected,
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
|
||||||
|
// rx DM from KAMIL to node 42 on channel 2 (packet id 1)
|
||||||
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 2));
|
||||||
|
|
||||||
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
// rx record + the directed #pong! reply on channel 2 (newest first — the reply is [0])
|
||||||
@@ -130,6 +162,60 @@ describe('MeshService wiring (event-driven trigger)', () => {
|
|||||||
text: '#pong!',
|
text: '#pong!',
|
||||||
channel: 2,
|
channel: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// wire-level: DM back to the sender on ch2 with replyId = the ping packet id (threaded quote)
|
||||||
|
await waitFor(() => outboundPackets(out).length >= 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(KAMIL_NUM);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1);
|
||||||
|
} finally {
|
||||||
|
svc.onModuleDestroy();
|
||||||
|
bridge.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a #ping broadcast on ch2 with a #pong! broadcast, same channel, threaded quote', async () => {
|
||||||
|
const bridge = await startBridge();
|
||||||
|
process.env.MESH_HOST = '127.0.0.1';
|
||||||
|
process.env.MESH_PORT = String(bridge.port);
|
||||||
|
const svc = new RealMeshService();
|
||||||
|
try {
|
||||||
|
svc.attachCommands(new CommandService(svc));
|
||||||
|
svc.onModuleInit();
|
||||||
|
await waitFor(
|
||||||
|
() => bridge.sockets.length === 1 && svc.status().connected,
|
||||||
|
);
|
||||||
|
|
||||||
|
const out: Uint8Array[] = [];
|
||||||
|
bridge.sockets[0].on('data', (c) => out.push(c));
|
||||||
|
|
||||||
|
// rx broadcast from KAMIL on channel 2 (packet id 1)
|
||||||
|
bridge.sockets[0].write(
|
||||||
|
textPacketFrame(KAMIL_NUM, BROADCAST_ADDR, '#ping', 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => svc.messages().length === 2);
|
||||||
|
expect(svc.messages()[0]).toMatchObject({
|
||||||
|
dir: 'tx',
|
||||||
|
to: BROADCAST_ADDR, // broadcast original → broadcast reply
|
||||||
|
text: '#pong!',
|
||||||
|
channel: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => outboundPackets(out).length >= 1);
|
||||||
|
const pkt = outboundPackets(out)[0]!;
|
||||||
|
expect(pkt.to).toBe(BROADCAST_ADDR);
|
||||||
|
expect(pkt.channel).toBe(2);
|
||||||
|
const data =
|
||||||
|
pkt.payloadVariant?.case === 'decoded'
|
||||||
|
? pkt.payloadVariant.value
|
||||||
|
: undefined;
|
||||||
|
expect(data?.replyId).toBe(1); // threaded quote of the ping packet (id 1)
|
||||||
} finally {
|
} finally {
|
||||||
svc.onModuleDestroy();
|
svc.onModuleDestroy();
|
||||||
bridge.close();
|
bridge.close();
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ export class CommandService {
|
|||||||
/**
|
/**
|
||||||
* Event-driven entry point called by MeshService for every decoded rx text.
|
* Event-driven entry point called by MeshService for every decoded rx text.
|
||||||
* No-op unless on COMMAND_CHANNEL with an exact `#command` match (case-sensitive).
|
* No-op unless on COMMAND_CHANNEL with an exact `#command` match (case-sensitive).
|
||||||
* Replies ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast.
|
* Replies go through MeshService.respond(id, reply): auto-routed to the original message's
|
||||||
* Never throws — a failed command must not crash the app.
|
* destination and channel as a threaded quote (replyId = id). Only when the trigger has no packet
|
||||||
|
* id does it fall back to legacy sendText routing on COMMAND_CHANNEL. Never throws — a failed
|
||||||
|
* command must not crash the app.
|
||||||
*/
|
*/
|
||||||
async onRx(
|
async onRx(
|
||||||
channel: number,
|
channel: number,
|
||||||
@@ -59,12 +61,18 @@ export class CommandService {
|
|||||||
try {
|
try {
|
||||||
const reply = await handler({ text: trimmed, from, id });
|
const reply = await handler({ text: trimmed, from, id });
|
||||||
if (reply === undefined || reply === null) return; // void = no reply
|
if (reply === undefined || reply === null) return; // void = no reply
|
||||||
await this.mesh?.sendText(
|
if (id !== undefined && this.mesh) {
|
||||||
from ?? BROADCAST_ADDR,
|
// respond() auto-routes the reply: rides the original record's channel with replyId = id (threaded quote)
|
||||||
reply,
|
await this.mesh.respond(id, reply);
|
||||||
undefined,
|
} else {
|
||||||
this.commandChannel,
|
// defensive fallback when the trigger has no packet id: legacy routing on COMMAND_CHANNEL
|
||||||
);
|
await this.mesh?.sendText(
|
||||||
|
from ?? BROADCAST_ADDR,
|
||||||
|
reply,
|
||||||
|
undefined,
|
||||||
|
this.commandChannel,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LlmService } from './llm.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [LlmService],
|
||||||
|
exports: [LlmService],
|
||||||
|
})
|
||||||
|
export class LlmModule {}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { ChatMessage } from './llm.service.js';
|
||||||
|
import { LlmService } from './llm.service.js';
|
||||||
|
|
||||||
|
const BASE = 'http://192.168.1.70:1111/v1';
|
||||||
|
|
||||||
|
function json(body: unknown): Response {
|
||||||
|
return { ok: true, status: 200, json: async () => body } as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh service with a clean env (explicit base/model/timeout). */
|
||||||
|
function makeService(): LlmService {
|
||||||
|
process.env.LLM_API_BASE = BASE;
|
||||||
|
process.env.LLM_MODEL = 'qwen3.8-27b';
|
||||||
|
process.env.LLM_TIMEOUT_MS = '60000';
|
||||||
|
return new LlmService();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LlmService', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
for (const k of ['LLM_API_BASE', 'LLM_MODEL', 'LLM_TIMEOUT_MS'])
|
||||||
|
delete process.env[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listModels returns the data[].id strings from GET /models', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({
|
||||||
|
data: [
|
||||||
|
{ id: 'qwen3.8-27b' },
|
||||||
|
{ id: 'text-embedding-nomic-embed-text-v1.5' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.listModels()).resolves.toEqual([
|
||||||
|
'qwen3.8-27b',
|
||||||
|
'text-embedding-nomic-embed-text-v1.5',
|
||||||
|
]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/models`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat posts model+messages and returns choices[0].message.content', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ choices: [{ message: { content: 'hello back' } }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: 'system', content: 'be brief' },
|
||||||
|
{ role: 'user', content: 'hi' },
|
||||||
|
];
|
||||||
|
await expect(
|
||||||
|
svc.chat(messages, { maxTokens: 50, temperature: 0.2 }),
|
||||||
|
).resolves.toBe('hello back');
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe(`${BASE}/chat/completions`);
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
model: 'qwen3.8-27b',
|
||||||
|
messages,
|
||||||
|
maxTokens: 50,
|
||||||
|
temperature: 0.2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat throws on non-2xx with the HTTP status in the message', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
text: async () => 'upstream down',
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(svc.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(
|
||||||
|
/HTTP 503.*upstream down/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarize calls chat with the system prompt plus the user text', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
json({ choices: [{ message: { content: 'the summary' } }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.summarize('a long text')).resolves.toBe('the summary');
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(JSON.parse(init.body).messages).toEqual([
|
||||||
|
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||||
|
{ role: 'user', content: 'a long text' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** A single chat message in OpenAI chat-completions shape. */
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LlmService {
|
||||||
|
// read at construction, like other services' env config
|
||||||
|
private readonly apiBase =
|
||||||
|
process.env.LLM_API_BASE ?? 'http://192.168.1.70:1111/v1';
|
||||||
|
private readonly model = process.env.LLM_MODEL ?? 'qwen3.8-27b';
|
||||||
|
// local 27B model can be slow — generous default
|
||||||
|
private readonly timeoutMs = parseInt(
|
||||||
|
process.env.LLM_TIMEOUT_MS ?? '60000',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** GET {base}/models → the `data[].id` strings. */
|
||||||
|
async listModels(): Promise<string[]> {
|
||||||
|
const res = await fetch(`${this.apiBase}/models`, {
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`listModels failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { data?: { id: string }[] };
|
||||||
|
return (body.data ?? []).map((m) => m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST {base}/chat/completions → `choices[0].message.content`. */
|
||||||
|
async chat(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
opts?: { maxTokens?: number; temperature?: number },
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${this.apiBase}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: this.model, messages, ...opts }),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`chat failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
choices?: { message?: { content?: string } }[];
|
||||||
|
};
|
||||||
|
const content = body.choices?.[0]?.message?.content;
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
throw new Error('chat failed: no choices[0].message.content in response');
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin wrapper over chat(): the stated primary use of this service. */
|
||||||
|
async summarize(text: string): Promise<string> {
|
||||||
|
return this.chat([
|
||||||
|
{ role: 'system', content: 'Summarize the following text concisely.' },
|
||||||
|
{ role: 'user', content: text },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-2
@@ -1,8 +1,8 @@
|
|||||||
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 { frame } from './codec.js';
|
import { extractFrames, frame } from './codec.js';
|
||||||
|
|
||||||
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
|
/** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */
|
||||||
export function startBridge(
|
export function startBridge(
|
||||||
@@ -176,6 +176,21 @@ export function textPacketFrame(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outbound text packets written to the bridge, in order (want_config frames skipped). */
|
||||||
|
export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return extractFrames(merged).packets.flatMap((p) => {
|
||||||
|
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
||||||
|
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const KAMIL_NUM = 123456789;
|
export const KAMIL_NUM = 123456789;
|
||||||
|
|
||||||
export function kamilNodeInfo(): NodeInfoFields {
|
export function kamilNodeInfo(): NodeInfoFields {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
metadataFrame,
|
metadataFrame,
|
||||||
moduleConfigSectionFrame,
|
moduleConfigSectionFrame,
|
||||||
nodeInfoFrame,
|
nodeInfoFrame,
|
||||||
|
outboundPackets,
|
||||||
startBridge,
|
startBridge,
|
||||||
telemetryPacketFrame,
|
telemetryPacketFrame,
|
||||||
textPacketFrame,
|
textPacketFrame,
|
||||||
@@ -667,21 +668,6 @@ describe('MeshService message history', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// outbound text packets written to the bridge, in order (want_config frames skipped)
|
|
||||||
function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return extractFrames(merged).packets.flatMap((p) => {
|
|
||||||
const tr = fromBinary(P.Mesh.ToRadioSchema, p);
|
|
||||||
if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MeshService respond', () => {
|
describe('MeshService respond', () => {
|
||||||
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
|
it('replies to an rx DM as a directed send back to the sender on the original channel with replyId = messageId', async () => {
|
||||||
const bridge = await startBridge();
|
const bridge = await startBridge();
|
||||||
|
|||||||
Reference in New Issue
Block a user