Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
052f87c
feat(registry): WP1 — MeshCoreManager implements ISourceManager + typ…
Yeraze Jul 7, 2026
05064f8
feat(registry): WP2 — meshcore sources register in unified sourceMana…
Yeraze Jul 8, 2026
bfde435
refactor(registry): WP3a — filter getAllManagers() loops by source ty…
Yeraze Jul 8, 2026
3903a74
test(registry): update tests for WP2+WP3a unified registry (#3962 Ph2)
Yeraze Jul 8, 2026
24979ff
test: fix type errors in WP1 test files (ConnectionType enum, display…
Yeraze Jul 8, 2026
bebc608
refactor(registry): migrate remaining meshcoreManagerRegistry read-on…
Yeraze Jul 8, 2026
3c0a035
test: stub the unified sourceManagerRegistry instead of meshcoreManag…
Yeraze Jul 8, 2026
ac6702d
refactor(registry): WP4 — deprecated shim, shim tests, cleanup, docs …
Yeraze Jul 8, 2026
ea5192a
fix(security): restore isMeshtasticManager guards in FIX-1 routes + r…
Yeraze Jul 8, 2026
4345ffa
fix(meshcoreRegistry): scope disconnectAll() to meshcore managers only
Yeraze Jul 8, 2026
9407feb
fix(meshcoreManager): sync pendingConfig in connect(); include source…
Yeraze Jul 8, 2026
b8aebe5
fix(meshcoreRoutes): cache manager in res.locals to eliminate TOCTOU …
Yeraze Jul 8, 2026
6f24e83
nit: server.ts debug log wording; CLAUDE.md disconnect semantics note
Yeraze Jul 8, 2026
516e599
style(api): convert channelRoutes+securityRoutes error responses to f…
Yeraze Jul 8, 2026
39d392d
refactor: extract ensureMeshCoreManagerStarted helper; collapse doubl…
Yeraze Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

| Subsystem | Primary files |
|-----------|---------------|
| Multi-source registry | `src/server/sourceManagerRegistry.ts`, `src/server/meshtasticManager.ts`, `src/server/meshcoreManager.ts` (parallel Meshcore protocol; not a fallback), `src/contexts/SourceContext.tsx` |
| Multi-source registry | `src/server/sourceManagerRegistry.ts` (single unified registry — both Meshtastic TCP and MeshCore managers live here as `ISourceManager`), `src/server/meshtasticManager.ts`, `src/server/meshcoreManager.ts`, `src/server/sourceManagerTypes.ts` (type-guard predicates `isMeshCoreManager`/`isMeshtasticManager`), `src/contexts/SourceContext.tsx`. `src/server/meshcoreRegistry.ts` is a `@deprecated` shim kept for one release — delete after #3962 Task 2.1. |
| Auth + permissions | `src/server/auth/`, `src/db/repositories/auth.ts`, `src/db/repositories/permissions.ts` |
| Database backends | `src/db/drivers/{sqlite,postgres,mysql}.ts`, `src/db/schema/`, `src/db/repositories/` |
| Migrations | `src/server/migrations/NNN_*.ts` (75+ total), registry in `src/db/migrations.ts` |
Expand Down Expand Up @@ -52,10 +52,12 @@ Existing bare-`{error}` handlers convert opportunistically as they're touched

## Multi-Source Architecture (4.x)

MeshMonitor 4.x supports **N concurrent Meshtastic node connections** ("sources"). Pre-4.0 code that referenced a singleton `meshtasticManager` is now a `@deprecated` JSDoc-tagged compatibility shim at the bottom of `src/server/meshtasticManager.ts` — IDEs will strikethrough usages but `tsc` does not error.
MeshMonitor 4.x supports **N concurrent node connections** ("sources"), covering Meshtastic TCP, MQTT broker/bridge, and MeshCore. All manager types implement `ISourceManager` (`src/server/sourceManagerRegistry.ts`) and live in the **single unified `sourceManagerRegistry`**. Pre-4.0 code that referenced a singleton `meshtasticManager` is now a `@deprecated` JSDoc-tagged compatibility shim at the bottom of `src/server/meshtasticManager.ts`. Similarly, `src/server/meshcoreRegistry.ts` is a `@deprecated` shim (landed in #3962 Task 2.1) — delete it after one release.

### Critical Rules
- **No global `meshtasticManager` singleton.** Look up per-source instances via `sourceManagerRegistry.getManager(sourceId)`.
- **One registry: `sourceManagerRegistry`.** MeshCore managers are registered here alongside Meshtastic/MQTT managers. To narrow a manager by type, use the type-guard predicates in `src/server/sourceManagerTypes.ts`: `isMeshCoreManager(m)` and `isMeshtasticManager(m)`. **Never** use `instanceof` or `as any[]` casts for this purpose. Loops over `getAllManagers()` that call meshtastic-specific methods must filter first (`getAllManagers().filter(isMeshtasticManager)`).
- **MeshCore disconnect semantics differ.** Calling `/disconnect` on a MeshCore source calls `manager.disconnect()` directly and **leaves the manager registered** (so `/api/sources/:id/meshcore/*` routes keep working). This is intentional: it is the one place where meshcore lifecycle deliberately DIVERGES from meshtastic (disconnect keeps the manager registered rather than removing it).
- **Every packet/node/message/telemetry/traceroute/neighbor/channel/embed-profile/ignored-node/distance-delete/time-sync/meshcore row carries a `sourceId`.** Migrations 020–062 are mostly source-scoping work. Repository queries that don't scope by `sourceId` will leak data across sources. **Exceptions (global-by-design):** (1) the `channel_database` (server-side decryption PSKs) — `channelDecryptionService` tries every enabled row regardless of source, and migration 063 dropped its dead `sourceId` column. (2) the `estimated_positions` table (issue #3271) — one row per physical `nodeNum`, pooled from traceroute + neighbor observations across ALL Meshtastic sources (incl. MQTT) by the scheduled `positionEstimationService`; estimation is **Meshtastic-only** (MeshCore excluded) and runs as a global batch job (`positionEstimationScheduler`), not in realtime. (3) the `automations` / `automation_runs` / `automation_variables` / `automation_variable_values` tables (issue #3653, Automation Engine) — automations and their user-defined variables are **global** (no `sourceId`); a workflow evaluates against events from every source, and a `condition.sourceFilter` block inside its `config` graph scopes it to a subset. Variable values are keyed by an explicit `scopeKey` (global/source/node/sourceNode) rather than a row-level `sourceId`. See `docs/internal/dev-notes/AUTOMATION_ENGINE_PLAN.md`. Adding a new per-source data type should still get a `sourceId` column unless you have a concrete cross-source-by-design reason like decryption.
- **Permissions are per-source.** `permissions.sourceId` was added in migration 022, refined in 033. `requirePermission(resource, action)` middleware honors source scoping.
- **Frontend uses `SourceContext`** (`src/contexts/SourceContext.tsx`). `useSource()` returns `{ sourceId, sourceName }`; `sourceId` is `null` outside a `SourceProvider` (legacy/single-source views).
Expand Down
11 changes: 11 additions & 0 deletions docs/internal/dev-notes/REMEDIATION_EPIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ Each unchecked phase = one worktree → architect spec → implementation → re
- [x] **1.5** — Response-envelope convention: `ok(res, data)` / `fail(res, status, code, msg)` helper for the `{ success, error, code }` envelope; documented in CLAUDE.md; new/modified handlers must use it.
- [x] **1.6** — Schema-drift tripwire: CI test diffing `createTables()` schema vs full migration replay (001→latest), normalized `sqlite_master`, fail on divergence.

## Phase 2 checklist

Phase 2 started 2026-07-07. Decisions carried forward from the Phase 0+1 interview: behavior-preserving; each numbered sub-task is one PR; full suite green before merge.

- [ ] **2.1** — `MeshCoreManager implements ISourceManager`: one unified `sourceManagerRegistry` for all source types; `meshcoreRegistry.ts` reduced to a `@deprecated` shim (delete after one release). WP1 (interface+guards+config move) → WP2+WP3a (lifecycle+loop hardening) → WP3b (read-site migration) → WP4 (shim+tests+docs). See `task21_spec.md` and PR on branch `feature/3962-p2-meshcore-isourcemanager`.
- [ ] **2.2a** — Heartbeat/status-probing: extract shared service parameterized by `ISourceManager` (plan §2.2 ¶1).
- [ ] **2.2b** — Auto-announce: MeshCore + Meshtastic cycles → one `autoAnnounceService` with per-protocol adapters (plan §2.2 ¶2).
- [ ] **2.2c** — Auto-responder: `checkAutoResponder` in both managers → shared service (plan §2.2 ¶3).
- [ ] **2.2d** — Distance-delete scheduling: MeshCore's `DistanceDeleteScheduler` construction → unified ownership in `services/` (plan §2.2 ¶4).
- [ ] **2.3** — Singleton retirement: enumerate legacy-singleton branches in `meshtasticManager.ts`; create a registry-managed default source from env config; reduce `export default` to a pure alias; delete last special-casing (plan §2.3).

## Ordering notes

- Phase 0 tasks are independent; executed serially in numeric order (one phase in flight at a time).
Expand Down
140 changes: 140 additions & 0 deletions src/server/meshcoreConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Tests for meshcoreConfig.ts — the config-conversion helpers moved out of
* meshcoreRegistry.ts as part of WP1 of the one-registry refactor (#3962 Ph2).
*
* Cases ported verbatim from meshcoreRegistry.test.ts `meshcoreConfigFromSource`
* suite; the originals will be removed in WP4.
*/
import { describe, it, expect } from 'vitest';
import { meshcoreConfigFromSource, virtualNodeConfigFromSource, DEFAULT_VIRTUAL_NODE_PORT } from './meshcoreConfig.js';
import { ConnectionType } from './meshcoreManager.js';
import type { Source } from '../db/repositories/sources.js';

function fakeSource(overrides: Partial<Source> = {}): Source {
return {
id: 'src-a',
name: 'A',
type: 'meshcore',
config: { transport: 'usb', port: '/dev/ttyACM0', deviceType: 'companion' },
enabled: true,
displayOrder: 0,
createdAt: 1,
updatedAt: 1,
createdBy: null,
...overrides,
};
}

describe('meshcoreConfigFromSource', () => {
it('maps companion-USB source config to a SERIAL MeshCoreConfig', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'usb', port: '/dev/ttyACM0', deviceType: 'companion' } }),
);
expect(cfg).toEqual({
connectionType: ConnectionType.SERIAL,
serialPort: '/dev/ttyACM0',
baudRate: 115200,
firmwareType: 'companion',
});
});

it('maps tcp source config when host is set', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'tcp', tcpHost: '10.0.0.5', tcpPort: 4404, deviceType: 'companion' } }),
);
expect(cfg).toEqual({
connectionType: ConnectionType.TCP,
tcpHost: '10.0.0.5',
tcpPort: 4404,
firmwareType: 'companion',
});
});

it('defaults tcpPort to 4403 when omitted', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'tcp', tcpHost: '10.0.0.5', deviceType: 'companion' } }),
);
expect(cfg).toEqual({
connectionType: ConnectionType.TCP,
tcpHost: '10.0.0.5',
tcpPort: 4403,
firmwareType: 'companion',
});
});

it('returns null for tcp transport without a host', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'tcp', tcpPort: 4403, deviceType: 'companion' } }),
);
expect(cfg).toBeNull();
});

it('returns null when companion-USB source has no port set (legacy seed default)', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'usb', port: '', deviceType: 'companion' } }),
);
expect(cfg).toBeNull();
});

it('passes heartbeatIntervalSeconds through on the SERIAL path', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({
config: { transport: 'usb', port: '/dev/ttyACM0', deviceType: 'companion', heartbeatIntervalSeconds: 30 },
}),
);
expect(cfg?.heartbeatIntervalSeconds).toBe(30);
});

it('passes heartbeatIntervalSeconds through on the TCP path', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({
config: { transport: 'tcp', tcpHost: '10.0.0.5', deviceType: 'companion', heartbeatIntervalSeconds: 45 },
}),
);
expect(cfg?.heartbeatIntervalSeconds).toBe(45);
});

it('leaves heartbeatIntervalSeconds undefined when not configured', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'usb', port: '/dev/ttyACM0', deviceType: 'companion' } }),
);
expect(cfg?.heartbeatIntervalSeconds).toBeUndefined();
});

it('maps repeater device type correctly', () => {
const cfg = meshcoreConfigFromSource(
fakeSource({ config: { transport: 'usb', port: '/dev/ttyUSB0', deviceType: 'repeater' } }),
);
expect(cfg?.firmwareType).toBe('repeater');
});
});

describe('virtualNodeConfigFromSource', () => {
it('returns undefined when virtualNode is not configured', () => {
expect(virtualNodeConfigFromSource({})).toBeUndefined();
});

it('returns undefined when enabled is false', () => {
expect(virtualNodeConfigFromSource({ virtualNode: { enabled: false, port: 5001 } })).toBeUndefined();
});

it('returns the config when enabled is true', () => {
const vn = virtualNodeConfigFromSource({ virtualNode: { enabled: true, port: 5001, allowAdminCommands: true } });
expect(vn).toEqual({ enabled: true, port: 5001, allowAdminCommands: true });
});

it(`falls back to DEFAULT_VIRTUAL_NODE_PORT (${DEFAULT_VIRTUAL_NODE_PORT}) when port is missing`, () => {
const vn = virtualNodeConfigFromSource({ virtualNode: { enabled: true } });
expect(vn?.port).toBe(DEFAULT_VIRTUAL_NODE_PORT);
});

it(`falls back to DEFAULT_VIRTUAL_NODE_PORT when port is 0`, () => {
const vn = virtualNodeConfigFromSource({ virtualNode: { enabled: true, port: 0 } });
expect(vn?.port).toBe(DEFAULT_VIRTUAL_NODE_PORT);
});

it('defaults allowAdminCommands to false when absent', () => {
const vn = virtualNodeConfigFromSource({ virtualNode: { enabled: true, port: 5001 } });
expect(vn?.allowAdminCommands).toBe(false);
});
});
121 changes: 121 additions & 0 deletions src/server/meshcoreConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* MeshCore source-config helpers.
*
* Converts a `sources.config` record (stored as JSON in the DB) into the
* runtime `MeshCoreConfig` shape that `MeshCoreManager.connect()` expects.
*
* Extracted from meshcoreRegistry.ts so this logic can be imported without
* pulling in the (now-deprecated) MeshCoreManagerRegistry class.
*/

import { ConnectionType, MeshCoreManager, type MeshCoreConfig } from './meshcoreManager.js';
import type { Source } from '../db/repositories/sources.js';
import { sourceManagerRegistry } from './sourceManagerRegistry.js';
import { isMeshCoreManager } from './sourceManagerTypes.js';
import { logger } from '../utils/logger.js';

export interface MeshCoreSourceConfig {
transport?: 'usb' | 'serial' | 'tcp';
port?: string;
serialPort?: string;
baudRate?: number;
tcpHost?: string;
tcpPort?: number;
deviceType?: 'companion' | 'repeater';
autoConnect?: boolean;
/**
* Companion heartbeat / auto-reconnect interval in seconds (0 = disabled).
* Mirrors the Meshtastic source setting. When > 0 the manager periodically
* probes the node (cheap RTC read) and, on repeated failure, tears down and
* reconnects with exponential backoff. Only honoured for companion devices
* (the native backend); repeater/direct-serial ignores it.
*/
heartbeatIntervalSeconds?: number;
// Virtual Node server — expose this node to the MeshCore app over WiFi (#3535).
virtualNode?: {
enabled?: boolean;
port?: number;
allowAdminCommands?: boolean;
};
}

/** Default TCP port the Virtual Node server listens on when none is given. */
export const DEFAULT_VIRTUAL_NODE_PORT = 5000;

/**
* Build the runtime virtual-node config from a source's saved config, or
* undefined when disabled/absent. A non-positive or missing port falls back to
* the default so an enabled server always binds to a usable port.
*/
export function virtualNodeConfigFromSource(cfg: MeshCoreSourceConfig): MeshCoreConfig['virtualNode'] {
const vn = cfg.virtualNode;
if (!vn?.enabled) return undefined;
return {
enabled: true,
port: typeof vn.port === 'number' && vn.port > 0 ? vn.port : DEFAULT_VIRTUAL_NODE_PORT,
allowAdminCommands: vn.allowAdminCommands === true,
};
}

/**
* Ensure a MeshCore manager is started for the given source.
*
* - If no manager is registered: creates one, configures it, and registers it
* (which auto-calls start() → connect()).
* - If a MeshCore manager is registered but disconnected: reconnects it with
* the supplied config.
* - If already registered and connected: logs a debug message and skips.
*
* This is the canonical create-or-connect recipe for MeshCore sources, shared
* by sourceRoutes.ts (auto-connect on create/enable/config-change) and
* server.ts (startup auto-connect loop).
*/
export async function ensureMeshCoreManagerStarted(source: Source, cfg: MeshCoreConfig): Promise<void> {
const existing = sourceManagerRegistry.getManager(source.id);
if (!existing) {
const mc = new MeshCoreManager(source.id, source.name);
mc.configure(cfg);
await sourceManagerRegistry.addManager(mc);
} else if (isMeshCoreManager(existing) && !existing.isConnected()) {
await existing.connect(cfg);
} else {
logger.debug(`[MeshCore:${source.id}] Manager already registered as meshcore and connected — skipping auto-connect`);
}
}

/**
* Convert a `sources.config` record into the runtime `MeshCoreConfig`
* shape that `MeshCoreManager.connect` expects. Supports companion-USB/serial
* and TCP transports. Returns null when the config is missing required fields.
*/
export function meshcoreConfigFromSource(source: Source): MeshCoreConfig | null {
const cfg = (source.config ?? {}) as MeshCoreSourceConfig;
const firmwareType = cfg.deviceType === 'repeater' ? 'repeater' : 'companion';
const virtualNode = virtualNodeConfigFromSource(cfg);

// Companion-USB / direct serial — the v1 path.
const port = cfg.serialPort || cfg.port;
if ((cfg.transport === 'usb' || cfg.transport === 'serial' || !cfg.transport) && port) {
return {
connectionType: ConnectionType.SERIAL,
serialPort: port,
baudRate: cfg.baudRate ?? 115200,
firmwareType,
virtualNode,
heartbeatIntervalSeconds: cfg.heartbeatIntervalSeconds,
};
}

if (cfg.transport === 'tcp' && cfg.tcpHost) {
return {
connectionType: ConnectionType.TCP,
tcpHost: cfg.tcpHost,
tcpPort: cfg.tcpPort ?? 4403,
firmwareType,
virtualNode,
heartbeatIntervalSeconds: cfg.heartbeatIntervalSeconds,
};
}

return null;
}
Loading
Loading