Add channel-2 #command auto-responder (v0.18.0)
CI / build + unit (pull_request) Successful in 41s

Event-driven CommandService dispatches exact case-sensitive #command
matches on COMMAND_CHANNEL (default 2) through a single-registration-point
registry; first command #ping -> #pong!. Replies ride COMMAND_CHANNEL:
directed DM back to the sender when present, else broadcast. Failing
handler/send only logs a warn.

DI: CommandModule's factory injects MeshService and attaches itself via
MeshService.attachCommands() — a constructor-level cycle leaves one side
null in NestJS 12's injector (verified), so no forwardRef is used.

Tests 106 -> 121; docs synced (AGENTS.md, MESHTASTIC.md, README).
This commit is contained in:
2026-09-25 17:11:52 +00:00
parent 2b2e494023
commit 931b42bb1e
11 changed files with 322 additions and 3 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { BatteryAlertModule } from './battery/battery.module.js';
import { CommandModule } from './commands/command.module.js';
import { MeshModule } from './mesh/mesh.module.js';
import { WeatherModule } from './weather/weather.module.js';
@Module({
imports: [MeshModule, WeatherModule, BatteryAlertModule],
imports: [MeshModule, WeatherModule, BatteryAlertModule, CommandModule],
})
export class AppModule {}
+22
View File
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { MeshModule } from '../mesh/mesh.module.js';
import { MeshService } from '../mesh/mesh.service.js';
import { CommandService } from './command.service.js';
@Module({
imports: [MeshModule], // one-way only — the reverse edge is wired in the factory below, not via DI
providers: [
{
provide: CommandService,
useFactory: (mesh: MeshService) => {
const commands = new CommandService(mesh);
// break the cycle: MeshService can't inject us (its own factory would deadlock on this edge), so attach ourselves once we exist — mesh is guaranteed created here because it's our injected dependency
mesh.attachCommands(commands);
return commands;
},
inject: [MeshService],
},
],
exports: [CommandService],
})
export class CommandModule {}
+164
View File
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import {
startBridge,
textPacketFrame,
waitFor,
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import type { MeshService } from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { CommandService } from './command.service.js';
function makeService(
sendText?: ReturnType<typeof vi.fn>,
): [CommandService, MeshService] {
const mesh = { sendText: sendText ?? vi.fn() } as unknown as MeshService;
return [new CommandService(mesh), mesh];
}
beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
});
describe('CommandService.onRx', () => {
it('answers #ping on the command channel with a directed DM back to the sender', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42, 7);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('broadcasts the reply when there is no sender', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping');
expect(mesh.sendText).toHaveBeenCalledWith(
BROADCAST_ADDR,
'#pong!',
undefined,
2,
);
});
it('ignores the same text on other channels (ch0 and ch3)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(0, '#ping', 42);
await svc.onRx(3, '#ping', 42);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('ignores non-command text on the command channel', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, 'hello there', 42);
await svc.onRx(2, 'ping', 42); // no leading # — not a command trigger
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('matches command names exactly and case-sensitively', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#Ping', 42);
await svc.onRx(2, '#ping!', 42); // extra char — no partial matches
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('trims surrounding whitespace before matching', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, ' #ping ', 42);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 2);
});
it('honors the COMMAND_CHANNEL env override', async () => {
process.env.COMMAND_CHANNEL = '5';
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // old default no longer triggers
expect(mesh.sendText).not.toHaveBeenCalled();
await svc.onRx(5, '#ping', 42);
expect(mesh.sendText).toHaveBeenCalledWith(42, '#pong!', undefined, 5);
});
it('sends nothing when a handler returns void', async () => {
const [svc, mesh] = makeService();
svc.register('silent', () => undefined);
await svc.onRx(2, '#silent', 42);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('is extensible: a second registered command dispatches through the same path', async () => {
const [svc, mesh] = makeService();
svc.register('status', (ctx) => `up from ${ctx.from ?? 'broadcast'}`);
await svc.onRx(2, '#status', 99, 5);
expect(mesh.sendText).toHaveBeenCalledWith(99, 'up from 99', undefined, 2);
});
it('swallows a failing handler (a bad command must not crash the app)', async () => {
const [svc, mesh] = makeService();
svc.register('boom', () => {
throw new Error('kaboom');
});
await expect(svc.onRx(2, '#boom', 42)).resolves.toBeUndefined();
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('swallows a failing send (mesh down) without throwing', async () => {
const [svc] = makeService(
vi.fn().mockRejectedValue(new Error('no bridge')),
);
await expect(svc.onRx(2, '#ping', 42)).resolves.toBeUndefined();
});
});
describe('MeshService wiring (event-driven trigger)', () => {
it('calls onRx for decoded rx text and answers #ping over the wire', 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 {
// wire the responder exactly like command.module.ts does in production
svc.attachCommands(new CommandService(svc));
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
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])
await waitFor(() => svc.messages().length === 2);
expect(svc.messages()[0]).toMatchObject({
dir: 'tx',
to: KAMIL_NUM,
text: '#pong!',
channel: 2,
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
it('does not answer #ping arriving on a non-command channel', 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,
);
bridge.sockets[0].write(textPacketFrame(KAMIL_NUM, 42, '#ping', 3));
await waitFor(() => svc.messages().length === 1); // only the rx record — no reply
expect(svc.messages()[0]).toMatchObject({
dir: 'rx',
text: '#ping',
channel: 3,
});
} finally {
svc.onModuleDestroy();
bridge.close();
}
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, Logger } from '@nestjs/common';
import { BROADCAST_ADDR } from '../mesh/codec.js';
// type-only: the instance comes from DI (command.module.ts factory) or direct construction in tests — no runtime import cycle with src/mesh
import type { MeshService } from '../mesh/mesh.service.js';
/** Context handed to a command handler: the trimmed text plus sender/packet id when present. */
export interface CommandContext {
/** the trimmed rx text, e.g. `#ping` */
text: string;
/** sender node num — omitted for broadcasts/unknown senders */
from?: number;
/** packet id of the triggering message */
id?: number;
}
/** A command handler: returns the reply text to send back, or void/undefined for no reply. */
export type CommandHandler = (
ctx: CommandContext,
) => string | undefined | Promise<string | undefined>;
@Injectable()
export class CommandService {
private readonly logger = new Logger(CommandService.name);
// read at construction, like other services' env config
private readonly commandChannel = parseInt(
process.env.COMMAND_CHANNEL ?? '2',
10,
);
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
private readonly commands = new Map<string, CommandHandler>();
/** mesh is optional so tests can construct without a bridge; production DI always passes one. */
constructor(private readonly mesh?: MeshService) {
this.register('ping', () => '#pong!');
}
/** 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);
}
/**
* 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).
* Replies ride COMMAND_CHANNEL: directed DM back to the sender when present, else broadcast.
* Never throws — a failed command must not crash the app.
*/
async onRx(
channel: number,
text: string,
from?: number,
id?: number,
): Promise<void> {
if (channel !== this.commandChannel) return;
const trimmed = text.trim();
if (!trimmed.startsWith('#')) return; // only `#command` texts trigger handlers
const handler = this.commands.get(trimmed.slice(1));
if (!handler) return; // exact, case-sensitive match — no partials
try {
const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply
await this.mesh?.sendText(
from ?? BROADCAST_ADDR,
reply,
undefined,
this.commandChannel,
);
} catch (err) {
this.logger.warn(
`[commands] '${trimmed}' failed: ${(err as Error).message}`,
);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { MeshService } from '../mesh/mesh.service.js';
import { CommandModule } from './command.module.js';
import { CommandService } from './command.service.js';
describe('DI wiring (module graph)', () => {
// compile() instantiates providers but does not run lifecycle hooks, so no TCP connection is opened.
it('resolves CommandService with MeshService provided by MeshModule', async () => {
process.env.MESH_DB_PATH = ':memory:'; // keep openMessageDb from touching the repo's ./data
const moduleRef = await Test.createTestingModule({
imports: [CommandModule],
}).compile();
expect(moduleRef.get(CommandService)).toBeInstanceOf(CommandService);
expect(moduleRef.get(MeshService)).toBeInstanceOf(MeshService);
});
// the MeshService <-> CommandService cycle must resolve to live references on both sides, not undefined
it('wires both sides of the circular dependency with real instances', async () => {
process.env.MESH_DB_PATH = ':memory:';
const moduleRef = await Test.createTestingModule({
imports: [CommandModule],
}).compile();
const mesh = moduleRef.get(MeshService);
const commands = moduleRef.get(CommandService);
expect((commands as unknown as { mesh?: MeshService }).mesh).toBe(mesh);
expect((mesh as unknown as { commands?: CommandService }).commands).toBe(
commands,
);
});
});
+17 -1
View File
@@ -11,6 +11,8 @@ import * as net from 'node:net';
// type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests)
import type { DatabaseSync } from 'node:sqlite';
import type * as Proto from '@meshtastic/protobufs';
// type-only: the instance arrives via attachCommands() in production (command.module.ts) or the constructor in tests — no runtime import cycle with src/commands
import type { CommandService } from '../commands/command.service.js';
import { OpencodeService } from '../opencode/opencode.service.js';
import {
BROADCAST_ADDR,
@@ -211,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
private db: DatabaseSync | null = null;
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
private readonly opencode: OpencodeService | null;
/** Event-driven `#command` auto-responder (COMMAND_CHANNEL); null until wired — via the constructor (tests) or attachCommands() (DI). */
private commands: CommandService | null = null;
private pollTimer: NodeJS.Timeout | null = null;
constructor(db?: DatabaseSync, opencode?: OpencodeService) {
constructor(
db?: DatabaseSync,
opencode?: OpencodeService,
commands?: CommandService,
) {
this.db = db ?? null;
this.opencode = opencode ?? null;
this.commands = commands ?? null;
}
/** Wire the `#command` auto-responder after construction — used by command.module.ts to break the DI cycle (a constructor-level MeshService <-> CommandService cycle leaves one side null in NestJS's injector). */
attachCommands(commands: CommandService): void {
this.commands = commands;
}
private socket: net.Socket | null = null;
@@ -736,6 +750,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
at: Date.now(),
});
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
// event-driven #command auto-responder (COMMAND_CHANNEL) — fire-and-forget like the notifier
void this.commands?.onRx(pkt.channel ?? 0, text, pkt.from, pkt.id);
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
// live position update: the Position is serialized in Data.payload
let pos: Proto.Mesh.Position | undefined;