Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
86 changes: 86 additions & 0 deletions src/server/meshcoreCompanionCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const PushCodes = {
SendConfirmed: 0x82,
MsgWaiting: 0x83,
LoginSuccess: 0x85,
StatusResponse: 0x87,
LogRxData: 0x88,
TraceData: 0x89,
TelemetryResponse: 0x8b,
Expand Down Expand Up @@ -242,6 +243,7 @@ export interface SetAdvertNameCmd { name: string; }
export interface SendLoginCmd { publicKey: string; password: string; }
export interface SendTracePathCmd { tag: number; auth: number; flags: number; path: Buffer; }
export interface SendTelemetryReqCmd { publicKey: string; }
export interface SendStatusReqCmd { publicKey: string; }
export interface SetRadioParamsCmd { freq: number; bw: number; sf: number; cr: number; }
export interface SetTxPowerCmd { power: number; }
export interface SetAdvertLatLonCmd { lat: number; lon: number; }
Expand Down Expand Up @@ -306,6 +308,18 @@ export function parseSendTelemetryReq(payload: Buffer): SendTelemetryReqCmd {
return { publicKey: payload.subarray(4, 36).toString('hex') };
}

/**
* SendStatusReq(27): `[code][publicKey:32]`. Unlike SendTelemetryReq there are
* no reserved bytes — the firmware/meshcore.js layout is just the command byte
* followed by the 32-byte target public key (see meshcore.js
* `sendCommandSendStatusReq`). Returns the target public key as a lowercase hex
* string for MeshCoreManager.requestNodeStatus.
*/
export function parseSendStatusReq(payload: Buffer): SendStatusReqCmd {
if (payload.length < 1 + 32) throw new Error('SendStatusReq: short payload');
return { publicKey: payload.subarray(1, 33).toString('hex') };
}

/**
* SetRadioParams(11): `[code][freq:u32LE][bw:u32LE][sf:u8][cr:u8]`.
* Wire freq is kHz and bw is Hz; returned in MHz / kHz for MeshCoreManager.setRadio.
Expand Down Expand Up @@ -681,6 +695,72 @@ export function encodeTelemetryResponsePush(
return Buffer.concat([b, Buffer.from(lppSensorData)]);
}

/**
* The 16-field repeater status blob the firmware pushes inside a StatusResponse
* (see meshcore.js `getStatus` → `repeaterStats`). All fields little-endian.
* Field names mirror MeshCoreManager's `MeshCoreStatus` so the manager's parsed
* result can be re-serialized verbatim (the mapping is lossless — every wire
* field has a `MeshCoreStatus` counterpart). Missing values encode as 0.
*/
export interface RepeaterStatusData {
batteryMv?: number;
queueLen?: number;
noiseFloor?: number;
lastRssi?: number;
packetsRecv?: number;
packetsSent?: number;
airTimeSecs?: number;
uptimeSecs?: number;
sentFlood?: number;
sentDirect?: number;
recvFlood?: number;
recvDirect?: number;
errors?: number;
lastSnr?: number;
directDups?: number;
floodDups?: number;
}

/** Serialize the 48-byte repeater status blob (inverse of meshcore.js `getStatus`). */
export function encodeRepeaterStatusData(s: RepeaterStatusData): Buffer {
const b = Buffer.alloc(48);
let o = 0;
b.writeUInt16LE((s.batteryMv ?? 0) & 0xffff, o); o += 2; // batt_milli_volts
b.writeUInt16LE((s.queueLen ?? 0) & 0xffff, o); o += 2; // curr_tx_queue_len
b.writeInt16LE(clampInt16(s.noiseFloor ?? 0), o); o += 2; // noise_floor
b.writeInt16LE(clampInt16(s.lastRssi ?? 0), o); o += 2; // last_rssi
b.writeUInt32LE((s.packetsRecv ?? 0) >>> 0, o); o += 4; // n_packets_recv
b.writeUInt32LE((s.packetsSent ?? 0) >>> 0, o); o += 4; // n_packets_sent
b.writeUInt32LE((s.airTimeSecs ?? 0) >>> 0, o); o += 4; // total_air_time_secs
b.writeUInt32LE((s.uptimeSecs ?? 0) >>> 0, o); o += 4; // total_up_time_secs
b.writeUInt32LE((s.sentFlood ?? 0) >>> 0, o); o += 4; // n_sent_flood
b.writeUInt32LE((s.sentDirect ?? 0) >>> 0, o); o += 4; // n_sent_direct
b.writeUInt32LE((s.recvFlood ?? 0) >>> 0, o); o += 4; // n_recv_flood
b.writeUInt32LE((s.recvDirect ?? 0) >>> 0, o); o += 4; // n_recv_direct
b.writeUInt16LE((s.errors ?? 0) & 0xffff, o); o += 2; // err_events
b.writeInt16LE(clampInt16(s.lastSnr ?? 0), o); o += 2; // last_snr
b.writeUInt16LE((s.directDups ?? 0) & 0xffff, o); o += 2; // n_direct_dups
b.writeUInt16LE((s.floodDups ?? 0) & 0xffff, o); // n_flood_dups (last field)
return b;
}

/**
* Encode a StatusResponse(0x87) push — the repeater stats a remote node returned
* for a SendStatusReq. Layout mirrors meshcore.js `onStatusResponsePush`:
* `[0x87][reserved:1][pubKeyPrefix:6][statusData:rest]`. The app correlates the
* push to its pending status request by the remote's 6-byte pubkey prefix.
*/
export function encodeStatusResponsePush(
pubKeyPrefix: Buffer | Uint8Array,
status: RepeaterStatusData,
): Buffer {
const head = Buffer.alloc(1 + 1 + 6);
head[0] = PushCodes.StatusResponse;
head[1] = 0; // reserved
Buffer.from(pubKeyPrefix).copy(head, 2, 0, 6);
return Buffer.concat([head, encodeRepeaterStatusData(status)]);
}

/**
* Encode a LogRxData(0x88) push — the node forwarding a raw received OTA packet
* to the app. This is the diagnostic "packet feed" that tools like
Expand Down Expand Up @@ -745,6 +825,12 @@ function clampInt8(v: number): number {
return Math.max(-128, Math.min(127, Math.trunc(v)));
}

/** Clamp a number to the signed-16-bit range (for int16 wire fields). */
function clampInt16(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(-32768, Math.min(32767, Math.trunc(v)));
}

/** Convert a hex public-key string to a 32-byte buffer (tolerant of `0x`/odd input). */
export function pubKeyHexToBytes(hex: string | undefined | null): Buffer {
const out = Buffer.alloc(32);
Expand Down
119 changes: 119 additions & 0 deletions src/server/meshcoreVirtualNodeServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ class FakeManager extends EventEmitter implements MeshCoreVirtualNodeManager {
loginToNodeMock = vi.fn().mockResolvedValue(true);
tracePathRawMock = vi.fn().mockResolvedValue({ pathSnrs: [8, 12], lastSnr: 5.5, pathLen: 2, flags: 0 });
requestRemoteTelemetryRawMock = vi.fn().mockResolvedValue(Buffer.from([0x01, 0x67, 0x00, 0xdc]));
requestNodeStatusMock = vi.fn().mockResolvedValue({
batteryMv: 4100,
queueLen: 3,
noiseFloor: -120,
lastRssi: -85,
packetsRecv: 1000,
packetsSent: 900,
airTimeSecs: 500,
uptimeSecs: 86400,
sentFlood: 100,
sentDirect: 200,
recvFlood: 300,
recvDirect: 400,
errors: 5,
lastSnr: 24, // int16 quarter-dB raw value as the wire carries it
directDups: 6,
floodDups: 7,
});
isConnected() { return this.localNode !== null; }
getLocalNode() { return this.localNode; }
getContacts() { return this.contacts; }
Expand Down Expand Up @@ -109,6 +127,9 @@ class FakeManager extends EventEmitter implements MeshCoreVirtualNodeManager {
requestRemoteTelemetryRaw(publicKey: string) {
return this.requestRemoteTelemetryRawMock(publicKey) as Promise<Buffer | null>;
}
requestNodeStatus(publicKey: string) {
return this.requestNodeStatusMock(publicKey) as Promise<Record<string, number> | null>;
}
emitMessage(msg: MeshCoreMessage) { this.emit('message', msg); }
emitSendConfirmed(data: { ackCode: number; roundTripMs: number }) { this.emit('send_confirmed', data); }
emitOtaPacket(data: { snr?: number | null; rssi?: number | null; raw_hex?: string | null }) {
Expand Down Expand Up @@ -890,3 +911,101 @@ describe('MeshCoreVirtualNodeServer — SendTelemetryReq relay (#3904)', () => {
expect(manager.requestRemoteTelemetryRawMock).not.toHaveBeenCalled();
});
});

// SendStatusReq(27): reply Sent, then push StatusResponse(0x87) with the remote
// key prefix + the 48-byte RepeaterStats blob re-encoded from the manager's
// parsed status (#3904). Read-only follow-up to login → not gated.
describe('MeshCoreVirtualNodeServer — SendStatusReq relay (#3904)', () => {
let server: MeshCoreVirtualNodeServer;
let client: TestClient;
let manager: FakeManager;

const REMOTE_KEY = 'd2'.repeat(32);
const REMOTE_KEY_BYTES = Buffer.from(REMOTE_KEY, 'hex');

async function startWith(allowAdminCommands: boolean): Promise<void> {
manager = new FakeManager();
server = new MeshCoreVirtualNodeServer({ port: 0, manager, allowAdminCommands, databaseService: CHANNELS_DB });
await server.start();
client = new TestClient();
await client.connect(server.getListeningPort()!);
}
afterEach(async () => { client?.close(); await server?.stop(); });

// [27][publicKey:32] — no reserved bytes (unlike SendTelemetryReq).
function statusFrame(publicKeyHex: string): number[] {
return [CommandCodes.SendStatusReq, ...Buffer.from(publicKeyHex, 'hex')];
}

it('replies Sent then pushes StatusResponse with key prefix + the 48-byte stats blob', async () => {
await startWith(false); // ungated
const frames = client.expectFrames(2);
client.send(statusFrame(REMOTE_KEY));
const [sent, push] = await frames;

expect(sent[0]).toBe(ResponseCodes.Sent);
// [0x87][reserved:1][pubKeyPrefix:6][statusData:48]
expect(push[0]).toBe(PushCodes.StatusResponse);
expect(push[1]).toBe(0); // reserved
expect(push.subarray(2, 8)).toEqual(REMOTE_KEY_BYTES.subarray(0, 6));
expect(push.length).toBe(1 + 1 + 6 + 48);

// Spot-check the little-endian RepeaterStats layout (offsets relative to
// the statusData start at byte 8).
const s = push.subarray(8);
expect(s.readUInt16LE(0)).toBe(4100); // batt_milli_volts
expect(s.readUInt16LE(2)).toBe(3); // curr_tx_queue_len
expect(s.readInt16LE(4)).toBe(-120); // noise_floor
expect(s.readInt16LE(6)).toBe(-85); // last_rssi
expect(s.readUInt32LE(8)).toBe(1000); // n_packets_recv
expect(s.readUInt32LE(12)).toBe(900); // n_packets_sent
expect(s.readUInt32LE(16)).toBe(500); // total_air_time_secs
expect(s.readUInt32LE(20)).toBe(86400); // total_up_time_secs
expect(s.readUInt16LE(40)).toBe(5); // err_events
expect(s.readInt16LE(42)).toBe(24); // last_snr
expect(s.readUInt16LE(44)).toBe(6); // n_direct_dups
expect(s.readUInt16LE(46)).toBe(7); // n_flood_dups
expect(manager.requestNodeStatusMock).toHaveBeenCalledWith(REMOTE_KEY);
});

it('relays status even when allowAdminCommands is off (read-only follow-up to login)', async () => {
await startWith(true);
const frames = client.expectFrames(2);
client.send(statusFrame(REMOTE_KEY));
const [sent, push] = await frames;
expect(sent[0]).toBe(ResponseCodes.Sent);
expect(push[0]).toBe(PushCodes.StatusResponse);
});

it('replies Sent but pushes nothing when the status request returns null', async () => {
await startWith(false);
manager.requestNodeStatusMock.mockResolvedValueOnce(null);
const sent = await client.request(statusFrame(REMOTE_KEY));
expect(sent[0]).toBe(ResponseCodes.Sent);
const second = await Promise.race([
client.expectFrames(1).then((f) => f[0]),
new Promise<null>((r) => setTimeout(() => r(null), 100)),
]);
expect(second).toBeNull();
});

it('replies Sent but pushes nothing when the manager throws', async () => {
await startWith(false);
manager.requestNodeStatusMock.mockRejectedValueOnce(new Error('bridge down'));
const sent = await client.request(statusFrame(REMOTE_KEY));
expect(sent[0]).toBe(ResponseCodes.Sent);
const second = await Promise.race([
client.expectFrames(1).then((f) => f[0]),
new Promise<null>((r) => setTimeout(() => r(null), 100)),
]);
expect(second).toBeNull();
});

it('replies Err(IllegalArg) on a short SendStatusReq payload, without querying the node', async () => {
await startWith(false);
const res = await client.request([CommandCodes.SendStatusReq, 1, 2, 3]); // < 33 bytes
expect(res[0]).toBe(ResponseCodes.Err);
expect(res[1]).toBe(ErrorCodes.IllegalArg);
expect(manager.requestNodeStatusMock).not.toHaveBeenCalled();
});
});
63 changes: 62 additions & 1 deletion src/server/meshcoreVirtualNodeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Server, Socket } from 'net';
import { EventEmitter } from 'events';
import { logger } from '../utils/logger.js';
import databaseService from '../services/database.js';
import type { MeshCoreNode, TelemetryMode, MeshCoreContact, MeshCoreMessage } from './meshcoreManager.js';
import type { MeshCoreNode, TelemetryMode, MeshCoreContact, MeshCoreMessage, MeshCoreStatus } from './meshcoreManager.js';
import {
CommandCodes,
ErrorCodes,
Expand Down Expand Up @@ -45,6 +45,8 @@ import {
parseSendLogin,
parseSendTracePath,
parseSendTelemetryReq,
parseSendStatusReq,
encodeStatusResponsePush,
type ParsedCommand,
} from './meshcoreCompanionCodec.js';

Expand Down Expand Up @@ -108,6 +110,13 @@ export interface MeshCoreVirtualNodeManager {
* SendTelemetryReq (issue #3904).
*/
requestRemoteTelemetryRaw(publicKey: string): Promise<Buffer | null>;
/**
* Request operational status from a remote node (repeater/room server) and
* return the parsed stats, or null on failure. Used to relay the app's
* SendStatusReq (issue #3904); the parsed fields are re-serialized to the
* wire status blob for the StatusResponse push.
*/
requestNodeStatus(publicKey: string): Promise<MeshCoreStatus | null>;
/** EventEmitter surface — the manager emits 'message' with a MeshCoreMessage. */
on(event: 'message', listener: (msg: MeshCoreMessage) => void): unknown;
off(event: 'message', listener: (msg: MeshCoreMessage) => void): unknown;
Expand Down Expand Up @@ -455,6 +464,12 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
// Remote telemetry request (issue #3904). Read-only — not gated.
void this.handleSendTelemetryReq(clientId, command);
break;
case CommandCodes.SendStatusReq:
// Remote status/owner-info request (issue #3904). Read-only follow-up
// to a login — not gated on allowAdminCommands (the real node answers
// a status request based on the session, not on our admin flag).
void this.handleSendStatusReq(clientId, command);
break;
// Config-mutating commands (issue #3904): forwarded to the real node
// only when `allowAdminCommands` is enabled; otherwise the app gets an
// explicit Err (UnsupportedCmd) instead of a silent hang.
Expand Down Expand Up @@ -585,6 +600,7 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
private readonly LOGIN_EST_TIMEOUT_MS = 12000;
private readonly TRACE_EST_TIMEOUT_MS = 30000;
private readonly TELEMETRY_EST_TIMEOUT_MS = 30000;
private readonly STATUS_EST_TIMEOUT_MS = 30000;

/**
* SendLogin(26): authenticate the physical node to a remote node with a
Expand Down Expand Up @@ -692,6 +708,51 @@ export class MeshCoreVirtualNodeServer extends EventEmitter {
}
}

/**
* SendStatusReq(27): request operational status (repeater stats / owner info)
* from a remote node and relay it (issue #3904). The app's flow — verified
* against ripplebiz/MeshCore firmware — is Sent → StatusResponse(0x87) push
* correlated by the remote's 6-byte pubkey prefix, so we reply Sent, fetch the
* status from the real node, then re-serialize it into the wire status blob and
* push it. On failure we emit nothing and let the app time out (mirrors real-
* node behaviour, where a failed status request produces no push).
*
* Not gated on allowAdminCommands — this is a read-only follow-up to login,
* like SendTelemetryReq; the real node answers based on the session, not our
* admin flag.
*
* Note on the status blob: `manager.requestNodeStatus` returns the parsed
* `MeshCoreStatus`, which we re-encode to the 48-byte `RepeaterStats` layout
* the app's decoder reads (meshcore.js `getStatus`). Firmware ≥1.16 appends
* two extra counters (total_rx_air_time_secs, n_recv_errors → 56 bytes) that
* the companion-protocol status view does not parse, so the 48-byte prefix is
* exactly what the app renders.
*/
private async handleSendStatusReq(clientId: string, command: ParsedCommand): Promise<void> {
let parsed;
try {
parsed = parseSendStatusReq(command.payload);
} catch (err) {
logger.warn(`[MeshCore VN ${this.sourceId}] SendStatusReq bad payload from ${clientId}: ${(err as Error).message}`);
this.send(clientId, encodeErr(ErrorCodes.IllegalArg));
return;
}
const keyShort = parsed.publicKey.substring(0, 12);
this.send(clientId, encodeSent(0, 0, this.STATUS_EST_TIMEOUT_MS));
try {
const status = await this.options.manager.requestNodeStatus(parsed.publicKey);
if (!status) {
logger.info(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} got no status`);
return;
}
const prefix = pubKeyHexToBytes(parsed.publicKey).subarray(0, 6);
this.send(clientId, encodeStatusResponsePush(prefix, status));
logger.info(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} → status relayed`);
} catch (err) {
logger.warn(`[MeshCore VN ${this.sourceId}] SendStatusReq to ${keyShort}… from ${clientId} failed: ${(err as Error).message}`);
}
}

private handleAppStart(clientId: string, command: ParsedCommand): void {
const localNode = this.options.manager.getLocalNode();
if (!localNode || !this.options.manager.isConnected()) {
Expand Down
Loading