import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import * as P from '@meshtastic/protobufs'; import * as net from 'node:net'; import type { AddressInfo } from 'node:net'; import { extractFrames, frame } from './codec.js'; /** In-process TCP stand-in for the meshtastic-serial-bridge; hands out accepted sockets. */ export function startBridge( onSocket?: (s: net.Socket) => void, ): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> { const sockets: net.Socket[] = []; const server = net.createServer((socket) => { onSocket?.(socket); // attached at accept time, before any data can arrive sockets.push(socket); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { resolve({ port: (server.address() as AddressInfo).port, sockets, close: () => server.close(), }); }); }); } export async function waitFor( cond: () => boolean | Promise, timeoutMs = 5000, ): Promise { const t0 = Date.now(); while (!(await cond())) { if (Date.now() - t0 > timeoutMs) throw new Error('timed out waiting'); await new Promise((r) => setTimeout(r, 10)); } } /** Plain-object NodeInfo fields (init shape, not the runtime message type) accepted by nodeInfoFrame. */ export interface NodeInfoFields { user?: { id?: string; longName?: string; shortName?: string; hwModel?: number; }; position?: { latitudeI?: number; longitudeI?: number; altitude?: number }; lastHeard?: number; deviceMetrics?: { batteryLevel?: number }; snr?: number; viaMqtt?: boolean; hopsAway?: number; } // init shape for create(): all fields optional (protobuf-es' MessageInit is not exported) type Init = { [P in keyof T as P extends '$unknown' | '$typeName' ? never : P]?: T[P]; }; /** DeviceMetadata frame as streamed once during the initial sync. */ export function metadataFrame(md: Init): Uint8Array { const fr = create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'metadata', value: md }, }); return frame(toBinary(P.Mesh.FromRadioSchema, fr)); } // Config/ModuleConfig section frames as streamed one-per-section during the initial sync. // (meshtastic.Config / meshtastic.ModuleConfig live in their own root namespaces, not under Mesh) export function configSectionFrame( sectionCase: P.Config.Config['payloadVariant']['case'], value: unknown, ): Uint8Array { const cfg = create(P.Config.ConfigSchema, { payloadVariant: { case: sectionCase, value, } as P.Config.Config['payloadVariant'], }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'config', value: cfg }, }), ), ); } export function moduleConfigSectionFrame( sectionCase: P.ModuleConfig.ModuleConfig['payloadVariant']['case'], value: unknown, ): Uint8Array { const mc = create(P.ModuleConfig.ModuleConfigSchema, { payloadVariant: { case: sectionCase, value, } as P.ModuleConfig.ModuleConfig['payloadVariant'], }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'moduleConfig', value: mc }, }), ), ); } export function nodeInfoFrame( num: number, fields?: NodeInfoFields, ): Uint8Array { const ni = create(P.Mesh.NodeInfoSchema, { num, ...fields }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'nodeInfo', value: ni }, }), ), ); } /** TELEMETRY_APP packet frame with a serialized Telemetry in Data.payload (live telemetry from any node). */ export function telemetryPacketFrame( from: number, variant: P.Telemetry.Telemetry['variant'], ): Uint8Array { const data = create(P.Mesh.DataSchema, { portnum: P.Portnums.PortNum.TELEMETRY_APP, payload: toBinary( P.Telemetry.TelemetrySchema, create(P.Telemetry.TelemetrySchema, { variant }), ), }); const pkt = create(P.Mesh.MeshPacketSchema, { from, id: 1, payloadVariant: { case: 'decoded', value: data }, }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'packet', value: pkt }, }), ), ); } /** Inbound text message: TEXT_MESSAGE_APP packet with UTF-8 in Data.payload (fixed packet id 1). */ export function textPacketFrame( from: number, to: number, text: string, channel = 0, ): Uint8Array { const data = create(P.Mesh.DataSchema, { portnum: P.Portnums.PortNum.TEXT_MESSAGE_APP, payload: new TextEncoder().encode(text), }); const pkt = create(P.Mesh.MeshPacketSchema, { from, to, id: 1, channel, payloadVariant: { case: 'decoded', value: data }, }); return frame( toBinary( P.Mesh.FromRadioSchema, create(P.Mesh.FromRadioSchema, { payloadVariant: { case: 'packet', value: pkt }, }), ), ); } /** Outbound text packets written to the bridge, in order (want_config frames skipped). */ export function outboundPackets(out: Uint8Array[]): P.Mesh.MeshPacket[] { const merged = new Uint8Array(out.reduce((n, c) => n + c.length, 0)); let off = 0; for (const c of out) { merged.set(c, off); off += c.length; } return extractFrames(merged).packets.flatMap((p) => { const tr = fromBinary(P.Mesh.ToRadioSchema, p); if (tr.payloadVariant?.case === 'packet') return [tr.payloadVariant.value]; return []; }); } export const KAMIL_NUM = 123456789; export function kamilNodeInfo(): NodeInfoFields { return { user: { id: '!kamil', longName: 'Kamil SP8MK/P', shortName: 'KK', hwModel: P.Mesh.HardwareModel.TBEAM, }, position: { latitudeI: 521049637, longitudeI: 209833152, altitude: 123 }, lastHeard: 1758000000, deviceMetrics: { batteryLevel: 87 }, }; }