Files
meshcore-bot/meshcore-bot.js
2025-09-12 21:22:12 +02:00

303 lines
9.9 KiB
JavaScript
Executable File

#!/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);
}