From 2bc71c976ba4fe21e853e567f2dce9c03f0b5222 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Thu, 21 Nov 2024 11:09:03 +1300 Subject: [PATCH 01/25] add .idea to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 1de9effd9a5805908ecf9ca312b56d2e825a1c78 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Thu, 21 Nov 2024 11:09:14 +1300 Subject: [PATCH 02/25] add open topo map layer --- src/public/index.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/public/index.html b/src/public/index.html index 68686bd..2e1fa54 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -2742,6 +2742,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' @@ -2761,6 +2766,7 @@ var tileLayers = { "OpenStreetMap": openStreetMapTileLayer, + "OpenTopoMap": openTopoMapTileLayer, "Esri Satellite": esriWorldImageryTileLayer, "Google Satellite": googleSatelliteTileLayer, "Google Hybrid": googleHybridTileLayer, From 3d11f77eb1675907ad74731dc32448d2d5c17452 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Thu, 21 Nov 2024 12:56:10 +1300 Subject: [PATCH 03/25] add cli option to forget outdated positions for nodes --- src/mqtt.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/mqtt.js b/src/mqtt.js index 379ac77..d667792 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,38 @@ 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), + }, + }, + data: { + latitude: null, + longitude: null, + altitude: null, + }, + }); + } catch(e) { + // do nothing + } + +} + function createNonce(packetId, fromNode) { // Expand packetId to 64 bits From 1c2f8d9be7f6fe625bb53136ffdc1aa8864ad6d9 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Thu, 21 Nov 2024 12:58:05 +1300 Subject: [PATCH 04/25] add index to position updated at column for nodes table --- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 prisma/migrations/20241120235736_add_index_to_position_updated_at_column_for_nodes_table/migration.sql 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/schema.prisma b/prisma/schema.prisma index a152277..41c36df 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") } From 34f43b7ee8aa30845297b635793e1117d9c48fe9 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sat, 23 Nov 2024 00:48:15 +1300 Subject: [PATCH 05/25] add example env file --- .env.example | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env.example 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" From 5ba3b95f6401a622d025e7012acf9bf55dcca85d Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sat, 23 Nov 2024 00:49:46 +1300 Subject: [PATCH 06/25] add example service file for running map ui and api --- meshtastic-map.service | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 meshtastic-map.service 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 From 8407f9da6a78be92a837a3cd447e6e241d9db684 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sat, 23 Nov 2024 00:52:35 +1300 Subject: [PATCH 07/25] add example service file for running mqtt collector --- meshtastic-map-mqtt.service | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 meshtastic-map-mqtt.service 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 From 53c104e3ada8c712604bb04c2d0ee95616a84212 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 24 Nov 2024 16:51:34 +1300 Subject: [PATCH 08/25] add dynamic terrain image to neighbour line popups --- src/public/index.html | 48 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/public/index.html b/src/public/index.html index 2e1fa54..b88a191 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3112,6 +3112,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(); @@ -3191,12 +3222,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, @@ -3308,12 +3343,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, @@ -3548,13 +3587,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()}` : '') - + `

Note: Some neighbour lines are clearly wrong.
Firmware older than v2.3.2 reports MQTT nodes as Neighbours.
Fixed in #3457
` + + `

Terrain images from HeyWhatsThat.com` + + `
`; line.bindTooltip(tooltip, { sticky: true, From f4295deb04790fa4d2d0b553a877b617093e71bb Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 24 Nov 2024 17:08:08 +1300 Subject: [PATCH 09/25] don't use node reported altitude for now --- src/public/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/public/index.html b/src/public/index.html index b88a191..a0cd7a6 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3121,13 +3121,13 @@ const node1MarkerColour = "0000FF"; // blue const node1Latitude = node1.latitude; const node1Longitude = node1.longitude; - const node1ElevationMSL = node1.altitude ?? ""; + 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 ?? ""; + const node2ElevationMSL = ""; // node2.altitude ?? ""; // generate terrain profile image url return "https://heywhatsthat.com/bin/profile-0904.cgi?" + new URLSearchParams({ From 24556746e09cb8493aec30786799f742e8a5ec27 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Wed, 11 Dec 2024 12:34:47 +1300 Subject: [PATCH 10/25] add link to meshtxt in readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 920df08..b476265 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ 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? From ac10e813432ddf2f3d77c5efbc4ee7b9fec0c3b7 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Thu, 12 Dec 2024 14:14:20 +1300 Subject: [PATCH 11/25] fix bug where forgetting outdated node positions updated the updated_at column when the node never had a position --- src/mqtt.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mqtt.js b/src/mqtt.js index d667792..27df269 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -584,6 +584,13 @@ async function forgetOutdatedNodePositions() { // 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, From 1b3d4c963538358857cfb4affdbbd6a9cd81f6d0 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Mon, 16 Dec 2024 00:23:10 +1300 Subject: [PATCH 12/25] =?UTF-8?q?show=20temperature=20in=20graph=20down=20?= =?UTF-8?q?to=20-20=C2=BAc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/index.html b/src/public/index.html index a0cd7a6..3c62efd 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -2269,7 +2269,7 @@ }, }, y: { - min: 0, + min: -20, max: 100, }, y1: { From 58e256de5d70e2793abd487689df63b63513644f Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 15:47:24 +1300 Subject: [PATCH 13/25] update telemetry proto --- src/protos/meshtastic/telemetry.proto | 325 +++++++++++++++++++++++--- 1 file changed, 294 insertions(+), 31 deletions(-) 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 From d90d4b0b28fd90824ce3d2a5e52d4a8789f5696d Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 16:09:55 +1300 Subject: [PATCH 14/25] collect wind environment metrics --- .../migration.sql | 5 +++++ prisma/schema.prisma | 4 ++++ src/mqtt.js | 8 ++++++++ 3 files changed, 17 insertions(+) create mode 100644 prisma/migrations/20241217025513_add_wind_columns_to_environment_metrics_table/migration.sql 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 41c36df..6741657 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,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/mqtt.js b/src/mqtt.js index 27df269..f960619 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1118,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; @@ -1151,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, }, }); } From 8cc0d4bd83c69b2137f56dc7aea73364a031833b Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 16:25:07 +1300 Subject: [PATCH 15/25] update api docs endpoint --- src/index.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/index.js b/src/index.js index a63a626..5882d6b 100644 --- a/src/index.js +++ b/src/index.js @@ -94,10 +94,46 @@ app.get('/api', async (req, res) => { "path": "/api/v1/nodes", "description": "Meshtastic nodes in JSON format.", }, + { + "path": "/api/v1/nodes/:nodeId", + "description": "Meshtastic node info in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/device-metrics", + "description": "Device metrics for a meshtastic node in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/environment-metrics", + "description": "Environment metrics for a meshtastic node in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/power-metrics", + "description": "Power metrics for a meshtastic node in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/neighbours", + "description": "Neighbours for a meshtastic node in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/traceroutes", + "description": "Trace Routes for a meshtastic node in JSON format.", + }, + { + "path": "/api/v1/nodes/:nodeId/position-history", + "description": "Position history for a meshtastic node in JSON format.", + }, { "path": "/api/v1/stats/hardware-models", "description": "Database statistics about hardware models in JSON format.", }, + { + "path": "/api/v1/text-messages", + "description": "Meshtastic text messages in JSON format.", + }, + { + "path": "/api/v1/text-messages/embed", + "description": "Meshtastic text messages rendered as an HTML page.", + }, { "path": "/api/v1/waypoints", "description": "Meshtastic waypoints in JSON format.", From a10aae3de0413a308417a9bed97dc3eb6eb4e33f Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 16:41:56 +1300 Subject: [PATCH 16/25] update docs --- src/index.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/index.js b/src/index.js index 5882d6b..888f99a 100644 --- a/src/index.js +++ b/src/index.js @@ -92,51 +92,51 @@ app.get('/api', async (req, res) => { }, { "path": "/api/v1/nodes", - "description": "Meshtastic nodes in JSON format.", + "description": "All meshtastic nodes", }, { "path": "/api/v1/nodes/:nodeId", - "description": "Meshtastic node info in JSON format.", + "description": "A specific meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/device-metrics", - "description": "Device metrics for a meshtastic node in JSON format.", + "description": "Device metrics for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/environment-metrics", - "description": "Environment metrics for a meshtastic node in JSON format.", + "description": "Environment metrics for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/power-metrics", - "description": "Power metrics for a meshtastic node in JSON format.", + "description": "Power metrics for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/neighbours", - "description": "Neighbours for a meshtastic node in JSON format.", + "description": "Neighbours for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/traceroutes", - "description": "Trace Routes for a meshtastic node in JSON format.", + "description": "Trace routes for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/position-history", - "description": "Position history for a meshtastic node in JSON format.", + "description": "Position history for a meshtastic node", }, { "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": "Meshtastic text messages in JSON format.", + "description": "Text messages", }, { "path": "/api/v1/text-messages/embed", - "description": "Meshtastic text messages rendered as an HTML page.", + "description": "Text messages rendered as an embeddable HTML page.", }, { "path": "/api/v1/waypoints", - "description": "Meshtastic waypoints in JSON format.", + "description": "Waypoints", }, ]; From 5efc7b57bd45d7893e086268bb4015db8c355cd4 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 16:57:47 +1300 Subject: [PATCH 17/25] add params to api docs --- src/index.js | 48 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 888f99a..61bd055 100644 --- a/src/index.js +++ b/src/index.js @@ -93,6 +93,10 @@ app.get('/api', async (req, res) => { { "path": "/api/v1/nodes", "description": "All meshtastic nodes", + "params": { + "role": "Filter by role", + "hardware_model": "Filter by hardware model", + }, }, { "path": "/api/v1/nodes/:nodeId", @@ -101,14 +105,29 @@ app.get('/api', async (req, res) => { { "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", @@ -121,6 +140,10 @@ app.get('/api', async (req, res) => { { "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", @@ -129,6 +152,15 @@ app.get('/api', async (req, res) => { { "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", @@ -140,11 +172,21 @@ app.get('/api', async (req, res) => { }, ]; - const html = links.map((link) => { - return `
  • ${link.path} - ${link.description}
  • `; + const linksHtml = links.map((link) => { + var line = `
  • `; + line += `${link.path} - ${link.description}`; + line += `
      `; + for(const paramKey in (link.params ?? [])){ + const paramDescription = link.params[paramKey]; + line += "
    • "; + line += `?${paramKey}: ${paramDescription}`; + line += `
    • `; + } + line += `
    `; + return line; }).join(""); - res.send(html); + res.send(`API Docs
      ${linksHtml}
    `); }); From 2bcca288e585d80548ec26948f019262fb0f7292 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 17 Dec 2024 20:21:08 +1300 Subject: [PATCH 18/25] add image for heltec t114 --- .../images/devices/HELTEC_MESH_NODE_T114.png | Bin 0 -> 36768 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/public/images/devices/HELTEC_MESH_NODE_T114.png 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 0000000000000000000000000000000000000000..aaa09b572b101c9cd3eb6306e406dd7f04bce63c GIT binary patch literal 36768 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

    `}feB1#e&|Hw`hBhNNr@;%~L{twCilVEltd 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 Date: Tue, 31 Dec 2024 16:09:07 +1300 Subject: [PATCH 19/25] update donate links --- README.md | 2 +- donate.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b476265..854146a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ twitter
    donate on ko-fi -donate bitcoin +donate bitcoin

    A map of all Meshtastic nodes heard via MQTT. 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) From 94c3126f27b3cfb486a968069d16fc2de3b3c215 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 07:00:55 +1300 Subject: [PATCH 20/25] add router_late role --- src/protos/meshtastic/config.proto | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/protos/meshtastic/config.proto b/src/protos/meshtastic/config.proto index 7ebbe45..fa080f7 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; } /* From 619aa07c4a8ae1eb024568ce670abf064ba0b1d5 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 07:01:25 +1300 Subject: [PATCH 21/25] add philippines regions --- src/protos/meshtastic/config.proto | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/protos/meshtastic/config.proto b/src/protos/meshtastic/config.proto index fa080f7..67149d4 100644 --- a/src/protos/meshtastic/config.proto +++ b/src/protos/meshtastic/config.proto @@ -755,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; } /* From 1ead63da36ef007c2bc6a7ff8c86db84a4d043e5 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 07:01:47 +1300 Subject: [PATCH 22/25] add short_turbo modem preset --- src/protos/meshtastic/config.proto | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/protos/meshtastic/config.proto b/src/protos/meshtastic/config.proto index 67149d4..9bca4d2 100644 --- a/src/protos/meshtastic/config.proto +++ b/src/protos/meshtastic/config.proto @@ -816,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; } /* From 7b19eb57406a31471da63872e7a085f770b8adf7 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 07:02:10 +1300 Subject: [PATCH 23/25] add new hardware models --- src/protos/meshtastic/mesh.proto | 54 +++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) 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. From 3bcdb2628adcbd76065b108f1e440ea2111d7c53 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 07:08:19 +1300 Subject: [PATCH 24/25] include router_late nodes in routers layer --- src/public/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/public/index.html b/src/public/index.html index 3c62efd..92ce175 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -3491,6 +3491,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); } From 43cf12015ddbd107bd5dfb32bc1ee98432b52eff Mon Sep 17 00:00:00 2001 From: liamcottle Date: Sun, 12 Jan 2025 23:32:53 +1300 Subject: [PATCH 25/25] add admin tool to purge all records for a provided node id --- src/admin.js | 127 +++++++++++++++++++++++++++++++++ src/utils/node_id_util.js | 23 ++++++ src/utils/node_id_util.test.js | 9 +++ 3 files changed, 159 insertions(+) create mode 100644 src/admin.js create mode 100644 src/utils/node_id_util.js create mode 100644 src/utils/node_id_util.test.js 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/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)); +});