diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 837247a..b7b6597 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,8 @@ jobs: - name: Dependencies run: npm ci --production + - name: Help + run: node meshcore-bot.js --help + - name: Smoke Test - run: node meshcore-bot.js "gibtesnicht" | grep "cannot open gibtesnicht" + run: node meshcore-bot.js --port "gibtesnicht" | grep "cannot open gibtesnicht" diff --git a/.gitignore b/.gitignore index 3c3629e..154a13d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +*.csv diff --git a/README.md b/README.md index 396df67..0d0c7d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # 🤖 MeshCore Bot -This script is a command bot that connects to a [MeshCore](https://github.com/meshcore-dev/MeshCore) companion radio device via serial connection and responds to commands received in private channels. +This script is a command bot that connects to a [MeshCore](https://github.com/meshcore-dev/MeshCore) companion radio device via USB serial connection and responds to commands received in private channels. + +> [!IMPORTANT] +> To prevent spam in public channels, this bot only responds in private channels! + +The bot is also able to fetch and log telemetry sensor (currently only voltage) data from a repeater node. +The telemetry data is logged to a Comma-Separated Values (CSV) file. +The interval at which the telemetry data is fetched can be configured. +This bot is ideal for testing MeshCore setup with repeater and distance of communication. | Client | Bot | |--------|-----| @@ -32,17 +40,34 @@ This script is a command bot that connects to a [MeshCore](https://github.com/me To run the bot use the following command: ```bash -node meshcore-bot.js [SERIAL_PORT] +node meshcore-bot.js --port [SERIAL_PORT] --repeater-public-key-prefix [REPEATER_PUBLIC_KEY_PREFIX] --repeater-password [REPEATER_PASSWORD] --telemetry-interval [TELEMETRY_INTERVAL_MINUTES] --csv [CSV_FILE] ``` -- `[SERIAL_PORT]` is optional. If not provided, the script will default to `/dev/cu.usbmodem1101`. +- `--port` or `-s` (optional): The serial port of the MeshCore device. Defaults to `/dev/cu.usbmodem1101`. +- `--repeater-public-key-prefix` or `-r` (optional): The public key prefix of a repeater node to fetch telemetry from. If provided, the telemetry feature is enabled. +- `--repeater-password` or `-p` (optional): The password for the repeater. By default, this is an empty string. +- `--telemetry-interval` or `-t` (optional): The interval in minutes at which telemetry data is retrieved from the repeater. The default value is `15`. +- `--csv` or `-c` (optional): The CSV file in which the repeater's telemetry data is to be logged. If this file is specified, the telemetry data will be logged in this file. -### Example +### Examples +**Basic:** ```bash -node meshcore-bot.js "/dev/ttyUSB0" +node meshcore-bot.js --port "/dev/ttyUSB0" ``` +**With Repeater Telemetry:** +```bash +node meshcore-bot.js --port "/dev/ttyUSB0" --repeater-public-key-prefix "935c6b694200644710a374c250c76f7aed9ec2ff3e60261447d4eda7c246ce5d" --repeater-password "your-password" --telemetry-interval 5 +``` +This will connect to the device on `/dev/ttyUSB0` and fetch telemetry from the specified repeater every 5 minutes. + +**With Repeater Telemetry and CSV Logging:** +```bash +node meshcore-bot.js --port "/dev/ttyUSB0" --repeater-public-key-prefix "935c6b694200644710a374c250c76f7aed9ec2ff3e60261447d4eda7c246ce5d" --repeater-password "your-password" --telemetry-interval 5 --csv "telemetry.csv" +``` +This will do the same as the previous example, but it will also log the telemetry data to `telemetry.csv`. + ### Commands - `.ping`: The bot will respond with "PONG! 🏓 (*hop count*)". diff --git a/meshcore-bot.js b/meshcore-bot.js index 5cb48c9..a9b23db 100644 --- a/meshcore-bot.js +++ b/meshcore-bot.js @@ -1,14 +1,91 @@ import { Constants, NodeJSSerialConnection } from "@liamcottle/meshcore.js"; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import fs from 'fs'; + +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 = "voltage"; + 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 prefix of the repeater to fetch telemetry from' + }) + .option('telemetryInterval', { + alias: 't', + type: 'number', + description: 'Telemetry 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 = process.argv[2] || "/dev/cu.usbmodem1101"; +const port = argv.port; +const repeaterPublicKeyPrefix = argv.repeaterPublicKeyPrefix; +const repeaterPassword = argv.repeaterPassword; +const telemetryIntervalMinutes = argv.telemetryInterval; +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 () => { @@ -16,15 +93,56 @@ 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; } - // update clock on meshcore device - await connection.syncDeviceTime(); - + 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 @@ -36,6 +154,11 @@ connection.on("disconnected", () => { reconnectInterval = setInterval(async () => { await connection.connect(); }, 3000); + + if (telemetryInterval) { + clearInterval(telemetryInterval); + telemetryInterval = null; + } }); // listen for new messages @@ -50,17 +173,17 @@ connection.on(Constants.PushCodes.MsgWaiting, async () => { } } } catch(e) { - console.log(e); + console.error("Message could not be retrieved", e); } }); async function onContactMessageReceived(message) { - console.log("[" + (new Date()).toISOString() + "] Contact message", message); + console.log(`[${new Date().toISOString()}] Contact message`, message); } async function onChannelMessageReceived(message) { message.senderTimestampISO = (new Date(message.senderTimestamp * 1000)).toISOString(); - console.log("[" + (new Date()).toISOString() + "] Channel message", message); + console.log(`[${new Date().toISOString()}] 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")){ @@ -74,6 +197,49 @@ async function onChannelMessageReceived(message) { } } +async function getRepeaterTelemetry(publicKeyPrefix, repeaterPassword) { + console.log("Fetching repeater telemetry..."); + try { + const contact = await connection.findContactByPublicKeyPrefix(Buffer.from(publicKeyPrefix, "hex")); + if(!contact){ + console.log("Repeater contact not found"); + return; + } + + // login to repeater and get repeater telemetry + console.log("Logging in to repeater..."); + await connection.login(contact.publicKey, repeaterPassword); + console.log("Fetching telemetry..."); + const telemetry = await connection.getTelemetry(contact.publicKey); + //console.log("Repeater telemetry", telemetry); + if (telemetry.lppSensorData) { + try { + const lpp = new LPPDecoder(); + const decoded = lpp.decode(telemetry.lppSensorData); + //console.log("Decoded repeater telemetry", decoded); + for (const sensor of decoded) { + if (sensor.name === "voltage") { + console.log(`Voltage: ${sensor.value} V`); + if (csvFile) { + const timestamp = new Date().toISOString(); + const csvRow = `${timestamp},${sensor.value}\n`; + if (!fs.existsSync(csvFile)) { + fs.writeFileSync(csvFile, 'timestamp,voltage\n'); + } + fs.appendFileSync(csvFile, csvRow); + } + } + } + } catch (e) { + console.error("Error decoding repeater telemetry", e); + } + } + + } catch(e) { + console.error("Error fetching repeater telemetry", e); + } +} + // connect to meshcore device try { await connection.connect(); diff --git a/package-lock.json b/package-lock.json index 33fe80d..4c7f710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,14 @@ { "name": "meshcore-bot", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { + "version": "1.0.0", "dependencies": { - "@liamcottle/meshcore.js": "^1.6.0" + "@liamcottle/meshcore.js": "^1.6.0", + "yargs": "^17.7.2" }, "devDependencies": { "@eslint/js": "^9.34.0", @@ -529,11 +532,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -597,11 +608,24 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -614,7 +638,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -663,6 +686,21 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -903,6 +941,15 @@ "dev": true, "license": "ISC" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -986,6 +1033,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1236,6 +1292,15 @@ "node": ">=6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1297,6 +1362,32 @@ "node": ">=8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -1372,6 +1463,59 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 2ee4767..8bddaf9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,12 @@ { "type": "module", + "version": "1.0.0", + "scripts": { + "start": "node meshcore-bot.js" + }, "dependencies": { - "@liamcottle/meshcore.js": "^1.6.0" + "@liamcottle/meshcore.js": "^1.6.0", + "yargs": "^17.7.2" }, "devDependencies": { "@eslint/js": "^9.34.0",