#!/usr/bin/env node import { Constants, NodeJSSerialConnection } from "@liamcottle/meshcore.js"; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import fs from 'fs'; function getTimestamp() { return new Date().toISOString().slice(0, -5) + 'Z'; } class LPPDecoder { // Decode Cayenne Low Power Payload (LPP) for LoraWan constructor() { this.sensors = []; } decode(data) { const buffer = Buffer.from(data); let i = 0; while (i < buffer.length) { const channel = buffer[i++]; const type = buffer[i++]; switch (type) { // Source: https://discord.com/channels/1343693475589263471/1391673743453192242/1395240557176950876 case 0x74: { // static const LPP_VOLTAGE = 116; const name = "lpp_volts"; this.sensors.push({ channel, type, name, value: buffer.readInt16BE(i) / 100 }); i += 2; // 2 bytes 0.01V unsigned break; } default: i = buffer.length; break; } } return this.sensors; } } const argv = yargs(hideBin(process.argv)) .option('port', { alias: 's', type: 'string', description: 'Serial port to connect to', default: '/dev/cu.usbmodem1101' }) .option('repeaterPublicKeyPrefix', { alias: 'r', type: 'string', description: 'Public key of the repeater to fetch telemetry from' }) .option('repeaterInterval', { alias: 'i', type: 'number', description: 'Repeater interval in minutes', default: 15 }) .option('repeaterPassword', { alias: 'p', type: 'string', description: 'Repeater password', default: '' }) .option('csv', { alias: 'c', type: 'string', description: 'CSV file to log telemetry to' }) .argv; // get port from cli arguments /*eslint no-undef: "off"*/ const port = argv.port; const repeaterPublicKeyPrefix = argv.repeaterPublicKeyPrefix; const repeaterPassword = argv.repeaterPassword; const telemetryIntervalMinutes = argv.repeaterInterval; const telemetryIntervalMs = telemetryIntervalMinutes * 60 * 1000; const csvFile = argv.csv; console.log(`Connecting to ${port}`); if(repeaterPublicKeyPrefix){ console.log(`Repeater public key prefix: ${repeaterPublicKeyPrefix}`); console.log(`Telemetry interval: ${telemetryIntervalMinutes} minutes`); if (csvFile) { console.log(`Logging telemetry to: ${csvFile}`); } } // create connection const connection = new NodeJSSerialConnection(port); let reconnectInterval; let telemetryInterval; // wait until connected connection.on("connected", async () => { // we are now connected console.log("Connected"); // update clock on meshcore device console.log("Sync Clock..."); try { await connection.syncDeviceTime(); } catch (e) { console.error("Error syncing device time", e); } // log contacts console.log("Get Contacts..."); try { const contacts = await connection.getContacts(); //console.log(`Contacts:`, contacts); for(const contact of contacts) { const typeNames = ["None", "Contact", "Repeater", "Room"]; const typeName = typeNames[contact.type] || "Unknown"; console.log(`${typeName}: ${contact.advName}; Public Key: ${Buffer.from(contact.publicKey).toString('hex')}`); } } catch (e) { console.error("Error retrieving contacts", e); } // log channels console.log("Get Channels..."); try { const channels = await connection.getChannels(); //console.log(`Channels:`, channels); for(const channel of channels) { if (channel.name) { console.log(`${channel.channelIdx}: ${channel.name}`); } } } catch (e) { console.error("Error retrieving channels", e); } // clear reconnect interval if it exists if (reconnectInterval) { clearInterval(reconnectInterval); reconnectInterval = null; } if(repeaterPublicKeyPrefix){ // Start telemetry fetching interval if (telemetryInterval) { clearInterval(telemetryInterval); } telemetryInterval = setInterval(() => getRepeaterTelemetry(repeaterPublicKeyPrefix, repeaterPassword), telemetryIntervalMs); getRepeaterTelemetry(repeaterPublicKeyPrefix, repeaterPassword); // Also fetch immediately on connect } }); // auto reconnect on disconnect connection.on("disconnected", () => { console.log("Disconnected, trying to reconnect..."); if (reconnectInterval) { clearInterval(reconnectInterval); } reconnectInterval = setInterval(async () => { await connection.connect(); }, 3000); if (telemetryInterval) { clearInterval(telemetryInterval); telemetryInterval = null; } }); // listen for new messages connection.on(Constants.PushCodes.MsgWaiting, async () => { try { const waitingMessages = await connection.getWaitingMessages(); for(const message of waitingMessages){ if(message.contactMessage){ await onContactMessageReceived(message.contactMessage); } else if(message.channelMessage) { await onChannelMessageReceived(message.channelMessage); } } } catch(e) { console.error("Message could not be retrieved", e); } }); async function onContactMessageReceived(message) { console.log(`[${getTimestamp()}] Contact message`, message); } async function onChannelMessageReceived(message) { message.senderTimestampISO = (new Date(message.senderTimestamp * 1000)).toISOString(); console.log(`[${getTimestamp()}] Channel message`, message); // handle commands only in own channels, not in public channel with id 0 if(message.channelIdx > 0){ if(message.text.includes(".ping")){ await connection.sendChannelTextMessage(message.channelIdx, "PONG! 🏓 (" + message.pathLen + ")"); return; } if(message.text.includes(".date")){ await connection.sendChannelTextMessage(message.channelIdx, (new Date()).toISOString()); return; } } } async function getRepeaterTelemetry(publicKeyPrefix, repeaterPassword) { console.log("Fetching repeater status and telemetry..."); try { const contact = await connection.findContactByPublicKeyPrefix(Buffer.from(publicKeyPrefix, "hex")); if(!contact){ console.error("Repeater contact not found"); return; } // login to repeater and get repeater status telemetry console.log("Logging in to repeater..."); await connection.login(contact.publicKey, repeaterPassword); // get repeater status console.log("Fetching status..."); const timestamp = getTimestamp(); // Store timestamp of first fetch for CSV const status = await connection.getStatus(contact.publicKey); console.log(`[${timestamp}] Repeater status`, status); // get repeater telemetry console.log("Fetching telemetry..."); const telemetry = await connection.getTelemetry(contact.publicKey); console.log(`[${getTimestamp()}] Repeater telemetry`, telemetry); let lpp_volts = 0.0; if (telemetry.lppSensorData) { try { const lpp = new LPPDecoder(); const decoded = lpp.decode(telemetry.lppSensorData); console.log(`[${getTimestamp()}] Decoded repeater telemetry`, decoded); for (const sensor of decoded) { if (sensor.name === "lpp_volts") { lpp_volts = sensor.value; console.log(`LPP Voltage: ${lpp_volts} V`); } } } catch (e) { console.error("Error decoding repeater telemetry", e); } } if (csvFile) { console.log("Write to CSV file..."); const header = [ 'timestamp', 'lpp_volts', 'batt_milli_volts', 'curr_tx_queue_len', 'noise_floor', 'last_rssi', 'n_packets_recv', 'n_packets_sent', 'total_air_time_secs', 'total_up_time_secs', 'n_sent_flood', 'n_sent_direct', 'n_recv_flood', 'n_recv_direct', 'err_events', 'last_snr', 'n_direct_dups', 'n_flood_dups' ].join(',') + '\n'; const statusValues = [ timestamp, lpp_volts, status.batt_milli_volts, status.curr_tx_queue_len, status.noise_floor, status.last_rssi, status.n_packets_recv, status.n_packets_sent, status.total_air_time_secs, status.total_up_time_secs, status.n_sent_flood, status.n_sent_direct, status.n_recv_flood, status.n_recv_direct, status.err_events, status.last_snr, status.n_direct_dups, status.n_flood_dups ].join(',') + '\n'; if (!fs.existsSync(csvFile)) { fs.writeFileSync(csvFile, header); } fs.appendFileSync(csvFile, statusValues); } console.log("Done, waiting for the next interval."); } catch(e) { console.error("Error fetching repeater status or telemetry", e); } } // connect to meshcore device try { await connection.connect(); } catch (e) { console.error("Failed to connect initially", e); }