Device info endpoint + richer node summaries (GET /api/device, MCP device_info) (#13)
CI / build + unit (push) Successful in 36s
CI / build + unit (push) Successful in 36s
This commit was merged in pull request #13.
This commit is contained in:
@@ -59,6 +59,35 @@ export interface NodeSummary {
|
||||
/** epoch seconds of the last heard packet; null when never heard */
|
||||
lastHeard: number | null;
|
||||
position?: { lat: number; lon: number; alt?: number };
|
||||
/** signal-to-noise ratio of the last received packet, dB (NodeInfo.snr) */
|
||||
snr?: number;
|
||||
/** hops from this node to the one that reported it (optional in proto) */
|
||||
hopsAway?: number;
|
||||
/** true when the node info arrived via MQTT rather than LoRa */
|
||||
viaMqtt?: boolean;
|
||||
}
|
||||
|
||||
/** Shape of deviceInfo() output: sync-captured device data, key material redacted. */
|
||||
export interface DeviceInfo {
|
||||
myNodeNum: number | null;
|
||||
firmwareVersion?: string;
|
||||
metadata?: Proto.Mesh.DeviceMetadata;
|
||||
localConfig: Record<string, Record<string, unknown>>;
|
||||
moduleConfig: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// key material must never leave the process — replaced with a marker so callers can see the field exists
|
||||
const REDACTED = 'REDACTED';
|
||||
/** Config sections that carry secrets: security (PKI keys) and network (wifi psk). */
|
||||
function redactSection(
|
||||
name: string,
|
||||
value: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (name !== 'security' && name !== 'network') return value;
|
||||
const out = { ...value };
|
||||
for (const key of ['publicKey', 'privateKey', 'adminKey', 'wifiPsk'])
|
||||
if (out[key] !== undefined) out[key] = REDACTED;
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -88,6 +117,10 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
>();
|
||||
// node database: NodeInfo frames from the initial sync + live POSITION_APP updates
|
||||
private nodeMap = new Map<number, NodeEntry>();
|
||||
// device metadata + per-section config/module-config streamed during the initial sync
|
||||
private metadata: Proto.Mesh.DeviceMetadata | null = null;
|
||||
private configSections = new Map<string, Record<string, unknown>>();
|
||||
private moduleConfigSections = new Map<string, Record<string, unknown>>();
|
||||
// message history (newest kept, capped at HISTORY_LIMIT) + pending tx records awaiting their routing ACK/NACK
|
||||
private history: MessageRecord[] = [];
|
||||
private pendingTx = new Map<number, MessageRecord>();
|
||||
@@ -132,9 +165,33 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Device info captured during the initial sync: this device's node number, firmware version and
|
||||
* metadata (DeviceMetadata as-is — it carries no key material), plus per-section config and
|
||||
* module-config messages keyed by section name. In-memory only; empty until the sync completes,
|
||||
* reset on every bridge reconnect. Key material is redacted: security publicKey/privateKey/adminKey
|
||||
* and network wifiPsk are replaced with "REDACTED" — never exposed over HTTP.
|
||||
*/
|
||||
deviceInfo(): DeviceInfo {
|
||||
const out: DeviceInfo = {
|
||||
myNodeNum: this.myNodeNum,
|
||||
localConfig: {},
|
||||
moduleConfig: {},
|
||||
};
|
||||
if (this.metadata) {
|
||||
out.firmwareVersion = this.metadata.firmwareVersion;
|
||||
out.metadata = this.metadata;
|
||||
}
|
||||
for (const [name, value] of this.configSections)
|
||||
out.localConfig[name] = redactSection(name, value);
|
||||
for (const [name, value] of this.moduleConfigSections)
|
||||
out.moduleConfig[name] = value;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lean summaries of all known nodes, sorted by node number.
|
||||
* Names/hwModel/batteryLevel appear only when the device reported them;
|
||||
* Names/hwModel/batteryLevel/snr/hopsAway/viaMqtt appear only when the device reported them;
|
||||
* `lastHeard` is epoch seconds (null = never heard); `position` only when both coordinates are known.
|
||||
*/
|
||||
async nodes(): Promise<NodeSummary[]> {
|
||||
@@ -247,9 +304,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
// omit UNSET (enum value 0) rather than report "UNSET"
|
||||
const hwName = n.user?.hwModel ? names.get(n.user.hwModel) : undefined;
|
||||
if (hwName && hwName !== 'UNSET') s.hwModel = hwName;
|
||||
// optional proto field: undefined when the device never reported metrics
|
||||
// optional proto fields: undefined when the device never reported them
|
||||
const battery = n.deviceMetrics?.batteryLevel;
|
||||
if (battery !== undefined) s.batteryLevel = battery;
|
||||
// non-optional proto scalars decode with their defaults (snr=0, viaMqtt=false), so a default value means "not reported"
|
||||
if (n.snr !== undefined && n.snr !== 0) s.snr = n.snr;
|
||||
if (n.hopsAway !== undefined) s.hopsAway = n.hopsAway; // optional in proto → truly absent when not reported
|
||||
if (n.viaMqtt === true) s.viaMqtt = true;
|
||||
const p = n.position;
|
||||
if (p?.latitudeI !== undefined && p.longitudeI !== undefined) {
|
||||
// alt included only when the device sent it — protobuf-es leaves absent optionals undefined, so an explicit 0 (sea level) is kept
|
||||
@@ -265,9 +326,12 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.socket = socket;
|
||||
socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes/history from the old session
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes/device info/history from the old session
|
||||
this.channelMap = new Map();
|
||||
this.nodeMap = new Map();
|
||||
this.metadata = null;
|
||||
this.configSections.clear();
|
||||
this.moduleConfigSections.clear();
|
||||
this.history = [];
|
||||
this.pendingTx.clear();
|
||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||
@@ -328,6 +392,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
// device streams the whole node DB during initial sync
|
||||
this.nodeMap.set(v.value.num, v.value);
|
||||
break;
|
||||
case 'metadata':
|
||||
// DeviceMetadata streamed once during initial sync (no key material in it)
|
||||
this.metadata = v.value;
|
||||
break;
|
||||
case 'config': {
|
||||
// device streams one Config per section during initial sync
|
||||
const sec = v.value.payloadVariant;
|
||||
if (sec?.case) this.configSections.set(sec.case, sec.value);
|
||||
break;
|
||||
}
|
||||
case 'moduleConfig': {
|
||||
const mod = v.value.payloadVariant;
|
||||
if (mod?.case) this.moduleConfigSections.set(mod.case, mod.value);
|
||||
break;
|
||||
}
|
||||
case 'configCompleteId':
|
||||
this.logger.log(
|
||||
`bridge ready (config complete), ${this.channelMap.size} channels, ${this.nodeMap.size} nodes`,
|
||||
|
||||
Reference in New Issue
Block a user