v0.18.0: channel-2 auto-responder with extensible #command boilerplate #38
@@ -27,3 +27,4 @@
|
|||||||
|
|
||||||
- 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.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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!`.
|
||||||
|
- Reply routing v1: directed DM back to the sender when present, else broadcast — always on `COMMAND_CHANNEL`, via the existing `MeshService.sendText(to, text, undefined, channel)` path. No threading/`replyId` in v1. A failing handler or send only logs a warn; it never crashes the app.
|
||||||
|
- Wiring: `MeshService` gains an optional third constructor param (`commands?`) plus a public `attachCommands()` — the rx handler calls `this.commands?.onRx(channel, text, from, id)` right after `notifyRx()`, fire-and-forget like the notifier. DI note: a constructor-level MeshService ↔ CommandService cycle leaves one side null in NestJS 12's injector (verified empirically), so `CommandModule` provides `CommandService` via a factory that injects `MeshService` and attaches itself as a side effect — no `forwardRef`, no module cycle.
|
||||||
|
- Tests: 106 → 121 specs — directed/broadcast replies on ch2, non-command-channel and non-command text ignored, case-sensitive exact match, whitespace trimming, `COMMAND_CHANNEL` override, void handler = no reply, extensibility (second registered command), failing handler/send swallowed; end-to-end over the fake bridge (`#ping` on ch2 answered with a directed `#pong!` tx record, ch3 not answered); DI spec asserts both sides of the wiring resolve to real instances.
|
||||||
|
- Docs: AGENTS.md "Other" bullet + MESHTASTIC.md "Channels & sending" describe the auto-responder; README env list gains `COMMAND_CHANNEL`.
|
||||||
|
|
||||||
## v0.17.0 — 2026-09-25 16:18
|
## v0.17.0 — 2026-09-25 16:18
|
||||||
|
|
||||||
- The mesh→agent notify gate is now a comma-separated multi-channel list: `MESH_NOTIFY_CHANNEL` (default `3`) parses to channel indices, each entry trimmed, empty/invalid entries ignored (`"3,0"` → `[3, 0]`).
|
- The mesh→agent notify gate is now a comma-separated multi-channel list: `MESH_NOTIFY_CHANNEL` (default `3`) parses to channel indices, each entry trimmed, empty/invalid entries ignored (`"3,0"` → `[3, 0]`).
|
||||||
|
|||||||
@@ -22,6 +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.
|
||||||
|
|
||||||
## Notifications (mesh → agent)
|
## Notifications (mesh → agent)
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ Config lives in `.env` (gitignored) and is passed via compose environment — cr
|
|||||||
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
- `MESH_RECONNECT_MS` (default `5000`) — reconnect interval in ms
|
||||||
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
- `MESH_SEND_CHANNEL` (default `3`) — channel index all sends target (`test`; never the primary channel)
|
||||||
- `MESH_NOTIFY_CHANNEL` (default `3`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified)
|
- `MESH_NOTIFY_CHANNEL` (default `3`) — comma-separated list of channel indices whose rx texts trigger the opencode notifier (see "Opencode notifier"; on ch0 only directed DMs notify, broadcasts ignored; synthetic injected messages, channel 0, are exempt and trigger it when `0` is in the list; messages from our own node are never notified)
|
||||||
|
- `COMMAND_CHANNEL` (default `2`) — channel index of the auto-responder: exact `#command` texts on that channel get an automatic reply (directed back to the sender, else broadcast); currently only `#ping` → `#pong!`
|
||||||
- `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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.17.0",
|
"version": "0.18.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
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 { 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],
|
imports: [MeshModule, WeatherModule, BatteryAlertModule, CommandModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
// 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 { DatabaseSync } from 'node:sqlite';
|
||||||
import type * as Proto from '@meshtastic/protobufs';
|
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 { OpencodeService } from '../opencode/opencode.service.js';
|
||||||
import {
|
import {
|
||||||
BROADCAST_ADDR,
|
BROADCAST_ADDR,
|
||||||
@@ -211,11 +213,23 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private db: DatabaseSync | null = null;
|
private db: DatabaseSync | null = null;
|
||||||
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
/** In-process opencode notifier for rx texts on the notify channel; null in tests that don't wire one. */
|
||||||
private readonly opencode: OpencodeService | null;
|
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;
|
private pollTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(db?: DatabaseSync, opencode?: OpencodeService) {
|
constructor(
|
||||||
|
db?: DatabaseSync,
|
||||||
|
opencode?: OpencodeService,
|
||||||
|
commands?: CommandService,
|
||||||
|
) {
|
||||||
this.db = db ?? null;
|
this.db = db ?? null;
|
||||||
this.opencode = opencode ?? 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;
|
private socket: net.Socket | null = null;
|
||||||
@@ -736,6 +750,8 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
|||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
});
|
});
|
||||||
this.notifyRx(pkt.channel ?? 0, text, pkt.from, pkt.id, pkt.to);
|
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) {
|
} else if (decoded && decoded.portnum === PORT_POSITION_APP) {
|
||||||
// live position update: the Position is serialized in Data.payload
|
// live position update: the Position is serialized in Data.payload
|
||||||
let pos: Proto.Mesh.Position | undefined;
|
let pos: Proto.Mesh.Position | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user