diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb7eebb --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL="mysql://root@localhost:3306/meshtastic-map?connection_limit=100" diff --git a/.gitignore b/.gitignore index 11ddd8d..559c261 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ node_modules # Keep environment variables out of version control .env diff --git a/README.md b/README.md index 920df08..854146a 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ twitter
donate on ko-fi -donate bitcoin +donate bitcoin

A map of all Meshtastic nodes heard via MQTT. My version of the map is available at https://meshtastic.liamcottle.net +> Check out my new Meshtastic Web Client: [MeshTXT](https://github.com/liamcottle/meshtxt) + ## How does it work? diff --git a/donate.md b/donate.md index 00c96df..f64f99b 100644 --- a/donate.md +++ b/donate.md @@ -4,6 +4,7 @@ Thank you for considering donating, this helps support my work on this project ## How can I donate? -- Bitcoin: 3FPBfiEwioWHFix3kZqe5bdU9F5o8mG8dh +- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q +- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D - Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle) -- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle) \ No newline at end of file +- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle) diff --git a/meshtastic-map-mqtt.service b/meshtastic-map-mqtt.service new file mode 100644 index 0000000..8a30052 --- /dev/null +++ b/meshtastic-map-mqtt.service @@ -0,0 +1,39 @@ +[Unit] +Description=meshtastic-map-mqtt +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=always +RestartSec=1 +User=liamcottle +WorkingDirectory=/home/liamcottle/meshtastic-map +ExecStart=/usr/bin/env node /home/liamcottle/meshtastic-map/src/mqtt.js \ + --mqtt-broker-url mqtt://127.0.0.1 \ + --mqtt-username username \ + --mqtt-password password \ + --mqtt-client-id meshtastic.example.com \ + --mqtt-topic 'msh/#' \ + --collect-positions \ + --collect-text-messages \ + --collect-waypoints \ + --ignore-direct-messages \ + --purge-interval-seconds 60 \ + --purge-nodes-unheard-for-seconds 604800 \ + --purge-device-metrics-after-seconds 604800 \ + --purge-environment-metrics-after-seconds 604800 \ + --purge-map-reports-after-seconds 604800 \ + --purge-neighbour-infos-after-seconds 604800 \ + --purge-power-metrics-after-seconds 604800 \ + --purge-positions-after-seconds 604800 \ + --purge-service-envelopes-after-seconds 604800 \ + --purge-text-messages-after-seconds 604800 \ + --purge-traceroutes-after-seconds 604800 \ + --purge-waypoints-after-seconds 604800 \ + --forget-outdated-node-positions-after-seconds 604800 \ + --drop-packets-not-ok-to-mqtt \ + --old-firmware-position-precision 16 + +[Install] +WantedBy=multi-user.target diff --git a/meshtastic-map.service b/meshtastic-map.service new file mode 100644 index 0000000..8b83b8d --- /dev/null +++ b/meshtastic-map.service @@ -0,0 +1,15 @@ +[Unit] +Description=meshtastic-map +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +Restart=always +RestartSec=1 +User=liamcottle +WorkingDirectory=/home/liamcottle/meshtastic-map +ExecStart=/usr/bin/env node /home/liamcottle/meshtastic-map/src/index.js + +[Install] +WantedBy=multi-user.target diff --git a/prisma/migrations/20241120235736_add_index_to_position_updated_at_column_for_nodes_table/migration.sql b/prisma/migrations/20241120235736_add_index_to_position_updated_at_column_for_nodes_table/migration.sql new file mode 100644 index 0000000..a574a65 --- /dev/null +++ b/prisma/migrations/20241120235736_add_index_to_position_updated_at_column_for_nodes_table/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX `nodes_position_updated_at_idx` ON `nodes`(`position_updated_at`); diff --git a/prisma/migrations/20241217025513_add_wind_columns_to_environment_metrics_table/migration.sql b/prisma/migrations/20241217025513_add_wind_columns_to_environment_metrics_table/migration.sql new file mode 100644 index 0000000..04070de --- /dev/null +++ b/prisma/migrations/20241217025513_add_wind_columns_to_environment_metrics_table/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE `environment_metrics` ADD COLUMN `wind_direction` INTEGER NULL, + ADD COLUMN `wind_gust` DECIMAL(65, 30) NULL, + ADD COLUMN `wind_lull` DECIMAL(65, 30) NULL, + ADD COLUMN `wind_speed` DECIMAL(65, 30) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a152277..6741657 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,7 @@ model Node { @@index(created_at) @@index(updated_at) + @@index(position_updated_at) @@index(node_id) @@map("nodes") } @@ -132,6 +133,10 @@ model EnvironmentMetric { voltage Decimal? current Decimal? iaq Int? + wind_direction Int? + wind_speed Decimal? + wind_gust Decimal? + wind_lull Decimal? created_at DateTime @default(now()) updated_at DateTime @default(now()) @updatedAt diff --git a/src/admin.js b/src/admin.js new file mode 100644 index 0000000..e9fbbef --- /dev/null +++ b/src/admin.js @@ -0,0 +1,127 @@ +// node src/admin.js --purge-node-id 123 +// node src/admin.js --purge-node-id '!AABBCCDD' + +const commandLineArgs = require("command-line-args"); +const commandLineUsage = require("command-line-usage"); + +// create prisma db client +const { PrismaClient } = require("@prisma/client"); +const NodeIdUtil = require("./utils/node_id_util"); +const prisma = new PrismaClient(); + +const optionsList = [ + { + name: 'help', + alias: 'h', + type: Boolean, + description: 'Display this usage guide.' + }, + { + name: "purge-node-id", + type: String, + description: "Purges all records for the provided node id.", + }, +]; + +// parse command line args +const options = commandLineArgs(optionsList); + +// show help +if(options.help){ + const usage = commandLineUsage([ + { + header: 'Meshtastic Map Admin', + content: 'Command line admin tool for the Meshtastic Map', + }, + { + header: 'Options', + optionList: optionsList, + }, + ]); + console.log(usage); + return; +} + +// get options and fallback to default values +const purgeNodeId = options["purge-node-id"] ?? null; + +async function purgeNodeById(nodeId) { + + // convert to numeric id + nodeId = NodeIdUtil.convertToNumeric(nodeId); + + // purge environment metrics + await prisma.environmentMetric.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge map reports + await prisma.mapReport.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge neighbour infos + await prisma.neighbourInfo.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge this node + await prisma.node.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge positions + await prisma.position.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge power metrics + await prisma.powerMetric.deleteMany({ + where: { + node_id: nodeId, + }, + }); + + // purge text messages + await prisma.textMessage.deleteMany({ + where: { + from: nodeId, + }, + }); + + // purge traceroutes + await prisma.traceRoute.deleteMany({ + where: { + from: nodeId, + }, + }); + + // purge waypoints + await prisma.waypoint.deleteMany({ + where: { + from: nodeId, + }, + }); + + console.log(`✅ Node '${nodeId}' has been purged from the database.`); + +} + +(async () => { + + // purge node by id + if(purgeNodeId){ + await purgeNodeById(purgeNodeId); + } + +})(); diff --git a/src/index.js b/src/index.js index a63a626..61bd055 100644 --- a/src/index.js +++ b/src/index.js @@ -92,23 +92,101 @@ app.get('/api', async (req, res) => { }, { "path": "/api/v1/nodes", - "description": "Meshtastic nodes in JSON format.", + "description": "All meshtastic nodes", + "params": { + "role": "Filter by role", + "hardware_model": "Filter by hardware model", + }, + }, + { + "path": "/api/v1/nodes/:nodeId", + "description": "A specific meshtastic node", + }, + { + "path": "/api/v1/nodes/:nodeId/device-metrics", + "description": "Device metrics for a meshtastic node", + "params": { + "count": "How many results to return", + "time_from": "Only include metrics created after this unix timestamp (milliseconds)", + "time_to": "Only include metrics created before this unix timestamp (milliseconds)", + }, + }, + { + "path": "/api/v1/nodes/:nodeId/environment-metrics", + "description": "Environment metrics for a meshtastic node", + "params": { + "count": "How many results to return", + "time_from": "Only include metrics created after this unix timestamp (milliseconds)", + "time_to": "Only include metrics created before this unix timestamp (milliseconds)", + }, + }, + { + "path": "/api/v1/nodes/:nodeId/power-metrics", + "description": "Power metrics for a meshtastic node", + "params": { + "count": "How many results to return", + "time_from": "Only include metrics created after this unix timestamp (milliseconds)", + "time_to": "Only include metrics created before this unix timestamp (milliseconds)", + }, + }, + { + "path": "/api/v1/nodes/:nodeId/neighbours", + "description": "Neighbours for a meshtastic node", + }, + { + "path": "/api/v1/nodes/:nodeId/traceroutes", + "description": "Trace routes for a meshtastic node", + }, + { + "path": "/api/v1/nodes/:nodeId/position-history", + "description": "Position history for a meshtastic node", + "params": { + "time_from": "Only include positions created after this unix timestamp (milliseconds)", + "time_to": "Only include positions created before this unix timestamp (milliseconds)", + }, }, { "path": "/api/v1/stats/hardware-models", - "description": "Database statistics about hardware models in JSON format.", + "description": "Database statistics about known hardware models", + }, + { + "path": "/api/v1/text-messages", + "description": "Text messages", + "params": { + "to": "Only include messages to this node id", + "from": "Only include messages from this node id", + "channel_id": "Only include messages for this channel id", + "gateway_id": "Only include messages gated to mqtt by this node id", + "last_id": "Only include messages before or after this id, based on results order", + "count": "How many results to return", + "order": "Order to return results in: asc, desc", + }, + }, + { + "path": "/api/v1/text-messages/embed", + "description": "Text messages rendered as an embeddable HTML page.", }, { "path": "/api/v1/waypoints", - "description": "Meshtastic waypoints in JSON format.", + "description": "Waypoints", }, ]; - const html = links.map((link) => { - return `
  • ${link.path} - ${link.description}
  • `; + const linksHtml = links.map((link) => { + var line = `
  • `; + line += `${link.path} - ${link.description}`; + line += ``; + return line; }).join(""); - res.send(html); + res.send(`API Docs
    `); }); diff --git a/src/mqtt.js b/src/mqtt.js index 379ac77..f960619 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -119,6 +119,11 @@ const optionsList = [ type: Number, description: "If provided, position packets from firmware v2.4 and older will be truncated to this many decimal places.", }, + { + name: "forget-outdated-node-positions-after-seconds", + type: Number, + description: "If provided, nodes that haven't sent a position report in this time will have their current position cleared.", + }, { name: "purge-interval-seconds", type: Number, @@ -221,6 +226,7 @@ const decryptionKeys = options["decryption-keys"] ?? [ const dropPacketsNotOkToMqtt = options["drop-packets-not-ok-to-mqtt"] ?? false; const dropPortnumsWithoutBitfield = options["drop-portnums-without-bitfield"] ?? null; const oldFirmwarePositionPrecision = options["old-firmware-position-precision"] ?? null; +const forgetOutdatedNodePositionsAfterSeconds = options["forget-outdated-node-positions-after-seconds"] ?? null; const purgeIntervalSeconds = options["purge-interval-seconds"] ?? 10; const purgeNodesUnheardForSeconds = options["purge-nodes-unheard-for-seconds"] ?? null; const purgeDeviceMetricsAfterSeconds = options["purge-device-metrics-after-seconds"] ?? null; @@ -269,6 +275,7 @@ if(purgeIntervalSeconds){ await purgeOldTextMessages(); await purgeOldTraceroutes(); await purgeOldWaypoints(); + await forgetOutdatedNodePositions(); }, purgeIntervalSeconds * 1000); } @@ -558,6 +565,45 @@ async function purgeOldWaypoints() { } +/** + * Clears the current position stored for nodes if the position hasn't been updated within the configured timeframe. + * This allows the node position to drop off the map if the user disabled position reporting, but still wants telemetry lookup etc + */ +async function forgetOutdatedNodePositions() { + + // make sure seconds provided + if(!forgetOutdatedNodePositionsAfterSeconds){ + return; + } + + // clear latitude/longitude/altitude for nodes that haven't updated their position in the configured timeframe + try { + await prisma.node.updateMany({ + where: { + position_updated_at: { + // position_updated_at before x seconds ago + lt: new Date(Date.now() - forgetOutdatedNodePositionsAfterSeconds * 1000), + }, + // don't forget outdated node positions for nodes that don't actually have a position set + // otherwise the updated_at is updated, when nothing changed + NOT: { + latitude: null, + longitude: null, + altitude: null, + }, + }, + data: { + latitude: null, + longitude: null, + altitude: null, + }, + }); + } catch(e) { + // do nothing + } + +} + function createNonce(packetId, fromNode) { // Expand packetId to 64 bits @@ -1072,6 +1118,10 @@ client.on("message", async (topic, message) => { const voltage = telemetry.environmentMetrics.voltage !== 0 ? telemetry.environmentMetrics.voltage : null; const current = telemetry.environmentMetrics.current !== 0 ? telemetry.environmentMetrics.current : null; const iaq = telemetry.environmentMetrics.iaq !== 0 ? telemetry.environmentMetrics.iaq : null; + const windDirection = telemetry.environmentMetrics.windDirection; + const windSpeed = telemetry.environmentMetrics.windSpeed; + const windGust = telemetry.environmentMetrics.windGust; + const windLull = telemetry.environmentMetrics.windLull; // set metrics to update on node table data.temperature = temperature; @@ -1105,6 +1155,10 @@ client.on("message", async (topic, message) => { voltage: voltage, current: current, iaq: iaq, + wind_direction: windDirection, + wind_speed: windSpeed, + wind_gust: windGust, + wind_lull: windLull, }, }); } diff --git a/src/protos/meshtastic/config.proto b/src/protos/meshtastic/config.proto index 7ebbe45..9bca4d2 100644 --- a/src/protos/meshtastic/config.proto +++ b/src/protos/meshtastic/config.proto @@ -95,6 +95,15 @@ message Config { * Uses position module configuration to determine TAK PLI broadcast interval. */ TAK_TRACKER = 10; + + /* + * Description: Will always rebroadcast packets, but will do so after all other modes. + * Technical Details: Used for router nodes that are intended to provide additional coverage + * in areas not already covered by other routers, or to bridge around problematic terrain, + * but should not be given priority over other routers in order to avoid unnecessaraily + * consuming hops. + */ + ROUTER_LATE = 11; } /* @@ -746,6 +755,21 @@ message Config { * Singapore 923mhz */ SG_923 = 18; + + /* + * Philippines 433mhz + */ + PH_433 = 19; + + /* + * Philippines 868mhz + */ + PH_868 = 20; + + /* + * Philippines 915mhz + */ + PH_915 = 21; } /* @@ -792,6 +816,13 @@ message Config { * Long Range - Moderately Fast */ LONG_MODERATE = 7; + + /* + * Short Range - Turbo + * This is the fastest preset and the only one with 500kHz bandwidth. + * It is not legal to use in all regions due to this wider bandwidth. + */ + SHORT_TURBO = 8; } /* diff --git a/src/protos/meshtastic/mesh.proto b/src/protos/meshtastic/mesh.proto index ad4155c..7f8b7e5 100644 --- a/src/protos/meshtastic/mesh.proto +++ b/src/protos/meshtastic/mesh.proto @@ -339,6 +339,11 @@ enum HardwareModel { */ HELTEC_HRU_3601 = 23; + /* + * Heltec Wireless Bridge + */ + HELTEC_WIRELESS_BRIDGE = 24; + /* * B&Q Consulting Station Edition G1: https://uniteng.com/wiki/doku.php?id=meshtastic:station */ @@ -427,7 +432,7 @@ enum HardwareModel { DR_DEV = 41; /* - * M5 esp32 based MCU modules with enclosure, TFT and LORA Shields. All Variants (Basic, Core, Fire, Core2, Paper) https://m5stack.com/ + * M5 esp32 based MCU modules with enclosure, TFT and LORA Shields. All Variants (Basic, Core, Fire, Core2, CoreS3, Paper) https://m5stack.com/ */ M5STACK = 42; @@ -620,9 +625,50 @@ enum HardwareModel { * */ RP2040_FEATHER_RFM95 = 76; - /* M5 esp32 based MCU modules with enclosure, TFT and LORA Shields. All Variants (Basic, Core, Fire, Core2, Paper) https://m5stack.com/ */ - M5STACK_COREBASIC=77; - M5STACK_CORE2=78; + + /* M5 esp32 based MCU modules with enclosure, TFT and LORA Shields. All Variants (Basic, Core, Fire, Core2, CoreS3, Paper) https://m5stack.com/ */ + M5STACK_COREBASIC = 77; + M5STACK_CORE2 = 78; + + /* Pico2 with Waveshare Hat, same as Pico */ + RPI_PICO2 = 79; + + /* M5 esp32 based MCU modules with enclosure, TFT and LORA Shields. All Variants (Basic, Core, Fire, Core2, CoreS3, Paper) https://m5stack.com/ */ + M5STACK_CORES3 = 80; + + /* Seeed XIAO S3 DK*/ + SEEED_XIAO_S3 = 81; + + /* + * Nordic nRF52840+Semtech SX1262 LoRa BLE Combo Module. nRF52840+SX1262 MS24SF1 + */ + MS24SF1 = 82; + + /* + * Lilygo TLora-C6 with the new ESP32-C6 MCU + */ + TLORA_C6 = 83; + + /* + * WisMesh Tap + * RAK-4631 w/ TFT in injection modled case + */ + WISMESH_TAP = 84; + + /* + * Similar to PORTDUINO but used by Routastic devices, this is not any + * particular device and does not run Meshtastic's code but supports + * the same frame format. + * Runs on linux, see https://github.com/Jorropo/routastic + */ + ROUTASTIC = 85; + + /* + * Mesh-Tab, esp32 based + * https://github.com/valzzu/Mesh-Tab + */ + MESH_TAB = 86; + /* * ------------------------------------------------------------------------------------------------------------------------------------------ * Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. diff --git a/src/protos/meshtastic/telemetry.proto b/src/protos/meshtastic/telemetry.proto index bd94666..78c0e4f 100644 --- a/src/protos/meshtastic/telemetry.proto +++ b/src/protos/meshtastic/telemetry.proto @@ -15,27 +15,27 @@ message DeviceMetrics { /* * 0-100 (>100 means powered) */ - uint32 battery_level = 1; + optional uint32 battery_level = 1; /* * Voltage measured */ - float voltage = 2; + optional float voltage = 2; /* * Utilization for the current channel, including well formed TX, RX and malformed RX (aka noise). */ - float channel_utilization = 3; + optional float channel_utilization = 3; /* * Percent of airtime for transmission used within the last hour. */ - float air_util_tx = 4; + optional float air_util_tx = 4; /* * How long the device has been running since the last reboot (in seconds) */ - uint32 uptime_seconds = 5; + optional uint32 uptime_seconds = 5; } /* @@ -45,38 +45,95 @@ message EnvironmentMetrics { /* * Temperature measured */ - float temperature = 1; + optional float temperature = 1; /* * Relative humidity percent measured */ - float relative_humidity = 2; + optional float relative_humidity = 2; /* * Barometric pressure in hPA measured */ - float barometric_pressure = 3; + optional float barometric_pressure = 3; /* * Gas resistance in MOhm measured */ - float gas_resistance = 4; + optional float gas_resistance = 4; /* * Voltage measured (To be depreciated in favor of PowerMetrics in Meshtastic 3.x) */ - float voltage = 5; + optional float voltage = 5; /* * Current measured (To be depreciated in favor of PowerMetrics in Meshtastic 3.x) */ - float current = 6; + optional float current = 6; - /* + /* * relative scale IAQ value as measured by Bosch BME680 . value 0-500. * Belongs to Air Quality but is not particle but VOC measurement. Other VOC values can also be put in here. */ - uint32 iaq = 7; + optional uint32 iaq = 7; + + /* + * RCWL9620 Doppler Radar Distance Sensor, used for water level detection. Float value in mm. + */ + optional float distance = 8; + + /* + * VEML7700 high accuracy ambient light(Lux) digital 16-bit resolution sensor. + */ + optional float lux = 9; + + /* + * VEML7700 high accuracy white light(irradiance) not calibrated digital 16-bit resolution sensor. + */ + optional float white_lux = 10; + + /* + * Infrared lux + */ + optional float ir_lux = 11; + + /* + * Ultraviolet lux + */ + optional float uv_lux = 12; + + /* + * Wind direction in degrees + * 0 degrees = North, 90 = East, etc... + */ + optional uint32 wind_direction = 13; + + /* + * Wind speed in m/s + */ + optional float wind_speed = 14; + + /* + * Weight in KG + */ + optional float weight = 15; + + /* + * Wind gust in m/s + */ + optional float wind_gust = 16; + + /* + * Wind lull in m/s + */ + optional float wind_lull = 17; + + /* + * Radiation in µR/h + */ + optional float radiation = 18; + } /* @@ -86,32 +143,32 @@ message PowerMetrics { /* * Voltage (Ch1) */ - float ch1_voltage = 1; + optional float ch1_voltage = 1; /* * Current (Ch1) */ - float ch1_current = 2; + optional float ch1_current = 2; /* * Voltage (Ch2) */ - float ch2_voltage = 3; + optional float ch2_voltage = 3; /* * Current (Ch2) */ - float ch2_current = 4; + optional float ch2_current = 4; /* * Voltage (Ch3) */ - float ch3_voltage = 5; + optional float ch3_voltage = 5; /* * Current (Ch3) */ - float ch3_current = 6; + optional float ch3_current = 6; } /* @@ -121,62 +178,147 @@ message AirQualityMetrics { /* * Concentration Units Standard PM1.0 */ - uint32 pm10_standard = 1; + optional uint32 pm10_standard = 1; /* * Concentration Units Standard PM2.5 */ - uint32 pm25_standard = 2; + optional uint32 pm25_standard = 2; /* * Concentration Units Standard PM10.0 */ - uint32 pm100_standard = 3; + optional uint32 pm100_standard = 3; /* * Concentration Units Environmental PM1.0 */ - uint32 pm10_environmental = 4; + optional uint32 pm10_environmental = 4; /* * Concentration Units Environmental PM2.5 */ - uint32 pm25_environmental = 5; + optional uint32 pm25_environmental = 5; /* * Concentration Units Environmental PM10.0 */ - uint32 pm100_environmental = 6; + optional uint32 pm100_environmental = 6; /* * 0.3um Particle Count */ - uint32 particles_03um = 7; + optional uint32 particles_03um = 7; /* * 0.5um Particle Count */ - uint32 particles_05um = 8; + optional uint32 particles_05um = 8; /* * 1.0um Particle Count */ - uint32 particles_10um = 9; + optional uint32 particles_10um = 9; /* * 2.5um Particle Count */ - uint32 particles_25um = 10; + optional uint32 particles_25um = 10; /* * 5.0um Particle Count */ - uint32 particles_50um = 11; + optional uint32 particles_50um = 11; /* * 10.0um Particle Count */ - uint32 particles_100um = 12; + optional uint32 particles_100um = 12; + + /* + * 10.0um Particle Count + */ + optional uint32 co2 = 13; +} + +/* + * Local device mesh statistics + */ +message LocalStats { + /* + * How long the device has been running since the last reboot (in seconds) + */ + uint32 uptime_seconds = 1; + /* + * Utilization for the current channel, including well formed TX, RX and malformed RX (aka noise). + */ + float channel_utilization = 2; + /* + * Percent of airtime for transmission used within the last hour. + */ + float air_util_tx = 3; + + /* + * Number of packets sent + */ + uint32 num_packets_tx = 4; + + /* + * Number of packets received (both good and bad) + */ + uint32 num_packets_rx = 5; + + /* + * Number of packets received that are malformed or violate the protocol + */ + uint32 num_packets_rx_bad = 6; + + /* + * Number of nodes online (in the past 2 hours) + */ + uint32 num_online_nodes = 7; + + /* + * Number of nodes total + */ + uint32 num_total_nodes = 8; + + /* + * Number of received packets that were duplicates (due to multiple nodes relaying). + * If this number is high, there are nodes in the mesh relaying packets when it's unnecessary, for example due to the ROUTER/REPEATER role. + */ + uint32 num_rx_dupe = 9; + + /* + * Number of packets we transmitted that were a relay for others (not originating from ourselves). + */ + uint32 num_tx_relay = 10; + + /* + * Number of times we canceled a packet to be relayed, because someone else did it before us. + * This will always be zero for ROUTERs/REPEATERs. If this number is high, some other node(s) is/are relaying faster than you. + */ + uint32 num_tx_relay_canceled = 11; +} + +/* + * Health telemetry metrics + */ +message HealthMetrics { + /* + * Heart rate (beats per minute) + */ + optional uint32 heart_bpm = 1; + + /* + * SpO2 (blood oxygen saturation) level + */ + optional uint32 spO2 = 2; + + /* + * Body temperature in degrees Celsius + */ + optional float temperature = 3; } /* @@ -208,6 +350,16 @@ message Telemetry { * Power Metrics */ PowerMetrics power_metrics = 5; + + /* + * Local device mesh statistics + */ + LocalStats local_stats = 6; + + /* + * Health telemetry metrics + */ + HealthMetrics health_metrics = 7; } } @@ -294,4 +446,115 @@ enum TelemetrySensorType { * BMP085/BMP180 High accuracy temperature and pressure (older Version of BMP280) */ BMP085 = 15; + + /* + * RCWL-9620 Doppler Radar Distance Sensor, used for water level detection + */ + RCWL9620 = 16; + + /* + * Sensirion High accuracy temperature and humidity + */ + SHT4X = 17; + + /* + * VEML7700 high accuracy ambient light(Lux) digital 16-bit resolution sensor. + */ + VEML7700 = 18; + + /* + * MLX90632 non-contact IR temperature sensor. + */ + MLX90632 = 19; + + /* + * TI OPT3001 Ambient Light Sensor + */ + OPT3001 = 20; + + /* + * Lite On LTR-390UV-01 UV Light Sensor + */ + LTR390UV = 21; + + /* + * AMS TSL25911FN RGB Light Sensor + */ + TSL25911FN = 22; + + /* + * AHT10 Integrated temperature and humidity sensor + */ + AHT10 = 23; + + /* + * DFRobot Lark Weather station (temperature, humidity, pressure, wind speed and direction) + */ + DFROBOT_LARK = 24; + + /* + * NAU7802 Scale Chip or compatible + */ + NAU7802 = 25; + + /* + * BMP3XX High accuracy temperature and pressure + */ + BMP3XX = 26; + + /* + * ICM-20948 9-Axis digital motion processor + */ + ICM20948 = 27; + + /* + * MAX17048 1S lipo battery sensor (voltage, state of charge, time to go) + */ + MAX17048 = 28; + + /* + * Custom I2C sensor implementation based on https://github.com/meshtastic/i2c-sensor + */ + CUSTOM_SENSOR = 29; + + /* + * MAX30102 Pulse Oximeter and Heart-Rate Sensor + */ + MAX30102 = 30; + + /* + * MLX90614 non-contact IR temperature sensor + */ + MLX90614 = 31; + + /* + * SCD40/SCD41 CO2, humidity, temperature sensor + */ + SCD4X = 32; + + /* + * ClimateGuard RadSens, radiation, Geiger-Muller Tube + */ + RADSENS = 33; + + /* + * High accuracy current and voltage + */ + INA226 = 34; + } + +/* + * NAU7802 Telemetry configuration, for saving to flash + */ +message Nau7802Config { + /* + * The offset setting for the NAU7802 + */ + int32 zeroOffset = 1; + + /* + * The calibration factor for the NAU7802 + */ + float calibrationFactor = 2; +} \ No newline at end of file diff --git a/src/public/images/devices/HELTEC_MESH_NODE_T114.png b/src/public/images/devices/HELTEC_MESH_NODE_T114.png new file mode 100644 index 0000000..aaa09b5 Binary files /dev/null and b/src/public/images/devices/HELTEC_MESH_NODE_T114.png differ diff --git a/src/public/index.html b/src/public/index.html index 98e4d3a..7291586 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -2245,7 +2245,7 @@ }, }, y: { - min: 0, + min: -20, max: 100, }, y1: { @@ -2718,6 +2718,11 @@ attribution: 'Tiles © OpenStreetMap | Data from Meshtastic', }); + var openTopoMapTileLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + maxZoom: 17, // open topo map doesn't have tiles closer than this + attribution: 'Tiles © OpenStreetMap', + }); + var esriWorldImageryTileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 21, // esri doesn't have tiles closer than this attribution: 'Tiles © Esri | Data from Meshtastic' @@ -2737,6 +2742,7 @@ var tileLayers = { "OpenStreetMap": openStreetMapTileLayer, + "OpenTopoMap": openTopoMapTileLayer, "Esri Satellite": esriWorldImageryTileLayer, "Google Satellite": googleSatelliteTileLayer, "Google Hybrid": googleHybridTileLayer, @@ -3082,6 +3088,37 @@ } + function getTerrainProfileImage(node1, node2) { + + // line colour between nodes + const lineColour = "0000FF"; // blue + + // node 1 (left side of image) + const node1MarkerColour = "0000FF"; // blue + const node1Latitude = node1.latitude; + const node1Longitude = node1.longitude; + const node1ElevationMSL = ""; // node1.altitude ?? ""; + + // node 2 (right side of image) + const node2MarkerColour = "0000FF"; // blue + const node2Latitude = node2.latitude; + const node2Longitude = node2.longitude; + const node2ElevationMSL = ""; // node2.altitude ?? ""; + + // generate terrain profile image url + return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({ + src: "meshtastic.liamcottle.net", + axes: 1, // include grid lines and a scale + metric: 1, // show metric units + curvature: 0, // don't include the curvature of the earth in the graphic + width: 500, + height: 200, + pt0: `${node1Latitude},${node1Longitude},${lineColour},${node1ElevationMSL},${node1MarkerColour}`, + pt1: `${node2Latitude},${node2Longitude},${lineColour},${node2ElevationMSL},${node2MarkerColour}`, + }).toString(); + + } + function showNodeNeighboursThatWeHeard(id) { cleanUpNodeNeighbours(); @@ -3161,12 +3198,16 @@ distance = `${distanceInKilometers} kilometers`; } + const terrainImageUrl = getTerrainProfileImage(node, neighbourNode); + const tooltip = `[${escapeString(node.short_name)}] ${escapeString(node.long_name)} heard [${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}` + `
    SNR: ${neighbour.snr}dB` + `
    Distance: ${distance}` + `

    ID: ${node.node_id} heard ${neighbourNode.node_id}` + `
    Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}` - + (node.neighbours_updated_at ? `
    Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : ''); + + (node.neighbours_updated_at ? `
    Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '') + + `

    Terrain images from HeyWhatsThat.com` + + `
    `; line.bindTooltip(tooltip, { sticky: true, @@ -3278,12 +3319,16 @@ distance = `${distanceInKilometers} kilometers`; } + const terrainImageUrl = getTerrainProfileImage(neighbourNode, node); + const tooltip = `[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)} heard [${escapeString(node.short_name)}] ${escapeString(node.long_name)}` + `
    SNR: ${neighbour.snr}dB` + `
    Distance: ${distance}` + `

    ID: ${neighbourNode.node_id} heard ${node.node_id}` + `
    Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}` - + (neighbourNode.neighbours_updated_at ? `
    Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : ''); + + (neighbourNode.neighbours_updated_at ? `
    Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '') + + `

    Terrain images from HeyWhatsThat.com` + + `
    `; line.bindTooltip(tooltip, { sticky: true, @@ -3422,6 +3467,7 @@ // add markers for routers and repeaters to routers layer group if(node.role_name === "ROUTER" || node.role_name === "ROUTER_CLIENT" + || node.role_name === "ROUTER_LATE" || node.role_name === "REPEATER"){ nodesRouterLayerGroup.addLayer(marker); } @@ -3518,13 +3564,17 @@ distance = `${distanceInKilometers} kilometers`; } + const terrainImageUrl = getTerrainProfileImage(node, neighbourNode); + const tooltip = `[${escapeString(node.short_name)}] ${escapeString(node.long_name)} heard [${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}` + `
    SNR: ${neighbour.snr}dB` + `
    Distance: ${distance}` + `

    ID: ${node.node_id} heard ${neighbourNode.node_id}` + `
    Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}` + (node.neighbours_updated_at ? `
    Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '') - + + `

    Terrain images from HeyWhatsThat.com` + + `
    `; + line.bindTooltip(tooltip, { sticky: true, opacity: 1, diff --git a/src/utils/node_id_util.js b/src/utils/node_id_util.js new file mode 100644 index 0000000..5397df1 --- /dev/null +++ b/src/utils/node_id_util.js @@ -0,0 +1,23 @@ +class NodeIdUtil { + + /** + * Converts the provided hex id to a numeric id, for example: !FFFFFFFF to 4294967295 + * Anything else will be converted as is to a BigInt, for example "4294967295" to 4294967295 + * @param hexIdOrNumber a node id in hex format with a prepended "!", or a numeric node id as a string or number + * @returns {bigint} the node id in numeric form + */ + static convertToNumeric(hexIdOrNumber) { + + // check if this is a hex id, and convert to numeric + if(hexIdOrNumber.toString().startsWith("!")){ + return BigInt('0x' + hexIdOrNumber.replaceAll("!", "")); + } + + // convert string or number to numeric + return BigInt(hexIdOrNumber); + + } + +} + +module.exports = NodeIdUtil; diff --git a/src/utils/node_id_util.test.js b/src/utils/node_id_util.test.js new file mode 100644 index 0000000..494c3bb --- /dev/null +++ b/src/utils/node_id_util.test.js @@ -0,0 +1,9 @@ +const NodeIdUtil = require("./node_id_util"); + +test('can convert hex id to numeric id', () => { + expect(NodeIdUtil.convertToNumeric("!FFFFFFFF")).toBe(BigInt(4294967295)); +}); + +test('can convert numeric id to numeric id', () => { + expect(NodeIdUtil.convertToNumeric(4294967295)).toBe(BigInt(4294967295)); +});