diff --git a/.gitignore b/.gitignore index f24831d..4f6ca97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/ node_modules */prisma +*/proto */protos */dist # Keep environment variables out of version control diff --git a/mqtt/Dockerfile b/mqtt/Dockerfile new file mode 100644 index 0000000..34b7f8c --- /dev/null +++ b/mqtt/Dockerfile @@ -0,0 +1,12 @@ +FROM node:lts-alpine3.17 + +# add project files to /app +ADD ./mqtt /app +ADD ./prisma /app/prisma +ADD ./protos /app/protos +WORKDIR /app + +# install node dependencies +RUN npm install && npx prisma generate + +ENTRYPOINT ["node", "index.js"] diff --git a/mqtt/index.js b/mqtt/index.js new file mode 100644 index 0000000..f960619 --- /dev/null +++ b/mqtt/index.js @@ -0,0 +1,1390 @@ +const crypto = require("crypto"); +const path = require("path"); +const mqtt = require("mqtt"); +const protobufjs = require("protobufjs"); +const commandLineArgs = require("command-line-args"); +const commandLineUsage = require("command-line-usage"); +const PositionUtil = require("./utils/position_util"); + +// create prisma db client +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +// meshtastic bitfield flags +const BITFIELD_OK_TO_MQTT_SHIFT = 0; +const BITFIELD_OK_TO_MQTT_MASK = (1 << BITFIELD_OK_TO_MQTT_SHIFT); + +const optionsList = [ + { + name: 'help', + alias: 'h', + type: Boolean, + description: 'Display this usage guide.' + }, + { + name: "mqtt-broker-url", + type: String, + description: "MQTT Broker URL (e.g: mqtt://mqtt.meshtastic.org)", + }, + { + name: "mqtt-username", + type: String, + description: "MQTT Username (e.g: meshdev)", + }, + { + name: "mqtt-password", + type: String, + description: "MQTT Password (e.g: large4cats)", + }, + { + name: "mqtt-client-id", + type: String, + description: "MQTT Client ID (e.g: map.example.com)", + }, + { + name: "mqtt-topic", + type: String, + multiple: true, + typeLabel: ' ...', + description: "MQTT Topic to subscribe to (e.g: msh/#)", + }, + { + name: "allowed-portnums", + type: Number, + multiple: true, + typeLabel: ' ...', + description: "If provided, only packets with these portnums will be processed.", + }, + { + name: "log-unknown-portnums", + type: Boolean, + description: "This option will log packets for unknown portnums to the console.", + }, + { + name: "collect-service-envelopes", + type: Boolean, + description: "This option will save all received service envelopes to the database.", + }, + { + name: "collect-positions", + type: Boolean, + description: "This option will save all received positions to the database.", + }, + { + name: "collect-text-messages", + type: Boolean, + description: "This option will save all received text messages to the database.", + }, + { + name: "ignore-direct-messages", + type: Boolean, + description: "This option will prevent saving direct messages to the database.", + }, + { + name: "collect-waypoints", + type: Boolean, + description: "This option will save all received waypoints to the database.", + }, + { + name: "collect-neighbour-info", + type: Boolean, + description: "This option will save all received neighbour infos to the database.", + }, + { + name: "collect-map-reports", + type: Boolean, + description: "This option will save all received map reports to the database.", + }, + { + name: "decryption-keys", + type: String, + multiple: true, + typeLabel: ' ...', + description: "Decryption keys encoded in base64 to use when decrypting service envelopes.", + }, + { + name: "drop-packets-not-ok-to-mqtt", + type: Boolean, + description: "This option will drop all packets that have 'OK to MQTT' set to false.", + }, + { + name: "drop-portnums-without-bitfield", + type: Number, + multiple: true, + typeLabel: ' ...', + description: "If provided, packets with these portnums will be dropped if they don't have a bitfield. (bitfield available from firmware v2.5+)", + }, + { + name: "old-firmware-position-precision", + 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, + description: "How long to wait between each automatic database purge.", + }, + { + name: "purge-device-metrics-after-seconds", + type: Number, + description: "Device Metrics older than this many seconds will be purged from the database.", + }, + { + name: "purge-environment-metrics-after-seconds", + type: Number, + description: "Environment Metrics older than this many seconds will be purged from the database.", + }, + { + name: "purge-power-metrics-after-seconds", + type: Number, + description: "Power Metrics older than this many seconds will be purged from the database.", + }, + { + name: "purge-map-reports-after-seconds", + type: Number, + description: "Map reports older than this many seconds will be purged from the database.", + }, + { + name: "purge-neighbour-infos-after-seconds", + type: Number, + description: "Neighbour infos older than this many seconds will be purged from the database.", + }, + { + name: "purge-nodes-unheard-for-seconds", + type: Number, + description: "Nodes that haven't been heard from in this many seconds will be purged from the database.", + }, + { + name: "purge-positions-after-seconds", + type: Number, + description: "Positions older than this many seconds will be purged from the database.", + }, + { + name: "purge-service-envelopes-after-seconds", + type: Number, + description: "Service envelopes older than this many seconds will be purged from the database.", + }, + { + name: "purge-text-messages-after-seconds", + type: Number, + description: "Text Messages older than this many seconds will be purged from the database.", + }, + { + name: "purge-traceroutes-after-seconds", + type: Number, + description: "Traceroutes older than this many seconds will be purged from the database.", + }, + { + name: "purge-waypoints-after-seconds", + type: Number, + description: "Waypoints older than this many seconds will be purged from the database.", + }, +]; + +// parse command line args +const options = commandLineArgs(optionsList); + +// show help +if(options.help){ + const usage = commandLineUsage([ + { + header: 'Meshtastic MQTT Collector', + content: 'Collects and processes service envelopes from a Meshtastic MQTT server.', + }, + { + header: 'Options', + optionList: optionsList, + }, + ]); + console.log(usage); + return; +} + +// get options and fallback to default values +const mqttBrokerUrl = options["mqtt-broker-url"] ?? "mqtt://mqtt.meshtastic.org"; +const mqttUsername = options["mqtt-username"] ?? "meshdev"; +const mqttPassword = options["mqtt-password"] ?? "large4cats"; +const mqttClientId = options["mqtt-client-id"] ?? null; +const mqttTopics = options["mqtt-topic"] ?? ["msh/#"]; +const allowedPortnums = options["allowed-portnums"] ?? null; +const logUnknownPortnums = options["log-unknown-portnums"] ?? false; +const collectServiceEnvelopes = options["collect-service-envelopes"] ?? false; +const collectPositions = options["collect-positions"] ?? false; +const collectTextMessages = options["collect-text-messages"] ?? false; +const ignoreDirectMessages = options["ignore-direct-messages"] ?? false; +const collectWaypoints = options["collect-waypoints"] ?? false; +const collectNeighbourInfo = options["collect-neighbour-info"] ?? false; +const collectMapReports = options["collect-map-reports"] ?? false; +const decryptionKeys = options["decryption-keys"] ?? [ + "1PG7OiApB1nwvP+rz05pAQ==", // add default "AQ==" decryption key +]; +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; +const purgeEnvironmentMetricsAfterSeconds = options["purge-environment-metrics-after-seconds"] ?? null; +const purgeMapReportsAfterSeconds = options["purge-map-reports-after-seconds"] ?? null; +const purgeNeighbourInfosAfterSeconds = options["purge-neighbour-infos-after-seconds"] ?? null; +const purgePowerMetricsAfterSeconds = options["purge-power-metrics-after-seconds"] ?? null; +const purgePositionsAfterSeconds = options["purge-positions-after-seconds"] ?? null; +const purgeServiceEnvelopesAfterSeconds = options["purge-service-envelopes-after-seconds"] ?? null; +const purgeTextMessagesAfterSeconds = options["purge-text-messages-after-seconds"] ?? null; +const purgeTraceroutesAfterSeconds = options["purge-traceroutes-after-seconds"] ?? null; +const purgeWaypointsAfterSeconds = options["purge-waypoints-after-seconds"] ?? null; + +// create mqtt client +const client = mqtt.connect(mqttBrokerUrl, { + username: mqttUsername, + password: mqttPassword, + clientId: mqttClientId, +}); + +// load protobufs +const root = new protobufjs.Root(); +root.resolvePath = (origin, target) => path.join(__dirname, "protos", target); +root.loadSync('meshtastic/mqtt.proto'); +const Data = root.lookupType("Data"); +const ServiceEnvelope = root.lookupType("ServiceEnvelope"); +const MapReport = root.lookupType("MapReport"); +const NeighborInfo = root.lookupType("NeighborInfo"); +const Position = root.lookupType("Position"); +const RouteDiscovery = root.lookupType("RouteDiscovery"); +const Telemetry = root.lookupType("Telemetry"); +const User = root.lookupType("User"); +const Waypoint = root.lookupType("Waypoint"); + +// run automatic purge if configured +if(purgeIntervalSeconds){ + setInterval(async () => { + await purgeUnheardNodes(); + await purgeOldDeviceMetrics(); + await purgeOldEnvironmentMetrics(); + await purgeOldMapReports(); + await purgeOldNeighbourInfos(); + await purgeOldPowerMetrics(); + await purgeOldPositions(); + await purgeOldServiceEnvelopes(); + await purgeOldTextMessages(); + await purgeOldTraceroutes(); + await purgeOldWaypoints(); + await forgetOutdatedNodePositions(); + }, purgeIntervalSeconds * 1000); +} + +/** + * Purges all nodes from the database that haven't been heard from within the configured timeframe. + */ +async function purgeUnheardNodes() { + + // make sure seconds provided + if(!purgeNodesUnheardForSeconds){ + return; + } + + // delete all nodes that were last updated before configured purge time + try { + await prisma.node.deleteMany({ + where: { + updated_at: { + // last updated before x seconds ago + lt: new Date(Date.now() - purgeNodesUnheardForSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all device metrics from the database that are older than the configured timeframe. + */ +async function purgeOldDeviceMetrics() { + + // make sure seconds provided + if(!purgeDeviceMetricsAfterSeconds){ + return; + } + + // delete all device metrics that are older than the configured purge time + try { + await prisma.deviceMetric.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeDeviceMetricsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all environment metrics from the database that are older than the configured timeframe. + */ +async function purgeOldEnvironmentMetrics() { + + // make sure seconds provided + if(!purgeEnvironmentMetricsAfterSeconds){ + return; + } + + // delete all environment metrics that are older than the configured purge time + try { + await prisma.environmentMetric.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeEnvironmentMetricsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all power metrics from the database that are older than the configured timeframe. + */ +async function purgeOldMapReports() { + + // make sure seconds provided + if(!purgeMapReportsAfterSeconds){ + return; + } + + // delete all map reports that are older than the configured purge time + try { + await prisma.mapReport.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeMapReportsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all neighbour infos from the database that are older than the configured timeframe. + */ +async function purgeOldNeighbourInfos() { + + // make sure seconds provided + if(!purgeNeighbourInfosAfterSeconds){ + return; + } + + // delete all neighbour infos that are older than the configured purge time + try { + await prisma.neighbourInfo.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeNeighbourInfosAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all power metrics from the database that are older than the configured timeframe. + */ +async function purgeOldPowerMetrics() { + + // make sure seconds provided + if(!purgePowerMetricsAfterSeconds){ + return; + } + + // delete all power metrics that are older than the configured purge time + try { + await prisma.powerMetric.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgePowerMetricsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all positions from the database that are older than the configured timeframe. + */ +async function purgeOldPositions() { + + // make sure seconds provided + if(!purgePositionsAfterSeconds){ + return; + } + + // delete all positions that are older than the configured purge time + try { + await prisma.position.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgePositionsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all service envelopes from the database that are older than the configured timeframe. + */ +async function purgeOldServiceEnvelopes() { + + // make sure seconds provided + if(!purgeServiceEnvelopesAfterSeconds){ + return; + } + + // delete all service envelopes that are older than the configured purge time + try { + await prisma.serviceEnvelope.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeServiceEnvelopesAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all text messages from the database that are older than the configured timeframe. + */ +async function purgeOldTextMessages() { + + // make sure seconds provided + if(!purgeTextMessagesAfterSeconds){ + return; + } + + // delete all text messages that are older than the configured purge time + try { + await prisma.textMessage.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeTextMessagesAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all traceroutes from the database that are older than the configured timeframe. + */ +async function purgeOldTraceroutes() { + + // make sure seconds provided + if(!purgeTraceroutesAfterSeconds){ + return; + } + + // delete all traceroutes that are older than the configured purge time + try { + await prisma.traceRoute.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeTraceroutesAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * Purges all waypoints from the database that are older than the configured timeframe. + */ +async function purgeOldWaypoints() { + + // make sure seconds provided + if(!purgeWaypointsAfterSeconds){ + return; + } + + // delete all waypoints that are older than the configured purge time + try { + await prisma.waypoint.deleteMany({ + where: { + created_at: { + // created before x seconds ago + lt: new Date(Date.now() - purgeWaypointsAfterSeconds * 1000), + }, + } + }); + } catch(e) { + // do nothing + } + +} + +/** + * 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 + const packetId64 = BigInt(packetId); + + // Initialize block counter (32-bit, starts at zero) + const blockCounter = 0; + + // Create a buffer for the nonce + const buf = Buffer.alloc(16); + + // Write packetId, fromNode, and block counter to the buffer + buf.writeBigUInt64LE(packetId64, 0); + buf.writeUInt32LE(fromNode, 8); + buf.writeUInt32LE(blockCounter, 12); + + return buf; + +} + +/** + * References: + * https://github.com/crypto-smoke/meshtastic-go/blob/develop/radio/aes.go#L42 + * https://github.com/pdxlocations/Meshtastic-MQTT-Connect/blob/main/meshtastic-mqtt-connect.py#L381 + */ +function decrypt(packet) { + + // attempt to decrypt with all available decryption keys + for(const decryptionKey of decryptionKeys){ + try { + + // convert encryption key to buffer + const key = Buffer.from(decryptionKey, "base64"); + + // create decryption iv/nonce for this packet + const nonceBuffer = createNonce(packet.id, packet.from); + + // determine algorithm based on key length + var algorithm = null; + if(key.length === 16){ + algorithm = "aes-128-ctr"; + } else if(key.length === 32){ + algorithm = "aes-256-ctr"; + } else { + // skip this key, try the next one... + console.error(`Skipping decryption key with invalid length: ${key.length}`); + continue; + } + + // create decipher + const decipher = crypto.createDecipheriv(algorithm, key, nonceBuffer); + + // decrypt encrypted packet + const decryptedBuffer = Buffer.concat([decipher.update(packet.encrypted), decipher.final()]); + + // parse as data message + return Data.decode(decryptedBuffer); + + } catch(e){} + } + + // couldn't decrypt + return null; + +} + +/** + * converts hex id to numeric id, for example: !FFFFFFFF to 4294967295 + * @param hexId a node id in hex format with a prepended "!" + * @returns {bigint} the node id in numeric form + */ +function convertHexIdToNumericId(hexId) { + return BigInt('0x' + hexId.replaceAll("!", "")); +} + +// subscribe to everything when connected +client.on("connect", () => { + for(const mqttTopic of mqttTopics){ + client.subscribe(mqttTopic); + } +}); + +// handle message received +client.on("message", async (topic, message) => { + try { + + // decode service envelope + const envelope = ServiceEnvelope.decode(message); + if(!envelope.packet){ + return; + } + + // attempt to decrypt encrypted packets + const isEncrypted = envelope.packet.encrypted?.length > 0; + if(isEncrypted){ + const decoded = decrypt(envelope.packet); + if(decoded){ + envelope.packet.decoded = decoded; + } + } + + // get portnum from decoded packet + const portnum = envelope.packet?.decoded?.portnum; + + // get bitfield from decoded packet + // bitfield was added in v2.5 of meshtastic firmware + // this value will be null for packets from v2.4.x and below, and will be an integer in v2.5.x and above + const bitfield = envelope.packet?.decoded?.bitfield; + + // check if we can see the decrypted packet data + if(envelope.packet.decoded != null){ + + // check if bitfield is available (v2.5.x firmware or newer) + if(bitfield != null){ + + // drop packets where "OK to MQTT" is false + const isOkToMqtt = bitfield & BITFIELD_OK_TO_MQTT_MASK; + if(dropPacketsNotOkToMqtt && !isOkToMqtt){ + return; + } + + } + + // if bitfield is not available for this packet, check if we want to drop this portnum + if(bitfield == null){ + + // drop packet if portnum is in drop list + // this is useful for dropping specific packet types from firmware older than v2.5 + if(dropPortnumsWithoutBitfield != null && dropPortnumsWithoutBitfield.includes(portnum)){ + return; + } + + } + + } + + // create service envelope in db + if(collectServiceEnvelopes){ + try { + await prisma.serviceEnvelope.create({ + data: { + mqtt_topic: topic, + channel_id: envelope.channelId, + gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, + to: envelope.packet.to, + from: envelope.packet.from, + protobuf: message, + }, + }); + } catch (e) { + console.error(e, { + envelope: envelope.packet, + }); + } + } + + // track when a node last gated a packet to mqtt + try { + await prisma.node.updateMany({ + where: { + node_id: convertHexIdToNumericId(envelope.gatewayId), + }, + data: { + mqtt_connection_state_updated_at: new Date(), + }, + }); + } catch(e) { + // don't care if updating mqtt timestamp fails + } + + const logKnownPacketTypes = false; + + // if allowed portnums are configured, ignore portnums that are not in the list + if(allowedPortnums != null && !allowedPortnums.includes(portnum)){ + return; + } + + if(portnum === 1) { + + if(!collectTextMessages){ + return; + } + + // check if we want to ignore direct messages + if(ignoreDirectMessages && envelope.packet.to !== 0xFFFFFFFF){ + return; + } + + if(logKnownPacketTypes) { + console.log("TEXT_MESSAGE_APP", { + to: envelope.packet.to.toString(16), + from: envelope.packet.from.toString(16), + text: envelope.packet.decoded.payload.toString(), + }); + } + + try { + await prisma.textMessage.create({ + data: { + to: envelope.packet.to, + from: envelope.packet.from, + channel: envelope.packet.channel, + packet_id: envelope.packet.id, + channel_id: envelope.channelId, + gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, + text: envelope.packet.decoded.payload.toString(), + rx_time: envelope.packet.rxTime, + rx_snr: envelope.packet.rxSnr, + rx_rssi: envelope.packet.rxRssi, + hop_limit: envelope.packet.hopLimit, + }, + }); + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 3) { + + const position = Position.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes){ + console.log("POSITION_APP", { + from: envelope.packet.from.toString(16), + position: position, + }); + } + + // process position + if(position.latitudeI != null && position.longitudeI){ + + // if bitfield is not available, we are on firmware v2.4 or below + // if configured, position packets should have their precision reduced + if(bitfield == null && oldFirmwarePositionPrecision != null){ + + // adjust precision of latitude and longitude + position.latitudeI = PositionUtil.setPositionPrecision(position.latitudeI, oldFirmwarePositionPrecision); + position.longitudeI = PositionUtil.setPositionPrecision(position.longitudeI, oldFirmwarePositionPrecision); + + // update position precision on packet to show that it is no longer full precision + position.precisionBits = oldFirmwarePositionPrecision; + + } + + // update node position in db + try { + await prisma.node.updateMany({ + where: { + node_id: envelope.packet.from, + }, + data: { + position_updated_at: new Date(), + latitude: position.latitudeI, + longitude: position.longitudeI, + altitude: position.altitude !== 0 ? position.altitude : null, + position_precision: position.precisionBits, + }, + }); + } catch (e) { + console.error(e); + } + + } + + // don't collect position history if not enabled, but we still want to update the node above + if(!collectPositions){ + return; + } + + try { + + // find an existing position with duplicate information created in the last 60 seconds + const existingDuplicatePosition = await prisma.position.findFirst({ + where: { + node_id: envelope.packet.from, + packet_id: envelope.packet.id, + created_at: { + gte: new Date(Date.now() - 60000), // created in the last 60 seconds + }, + } + }); + + // create position if no duplicates found + if(!existingDuplicatePosition){ + await prisma.position.create({ + data: { + node_id: envelope.packet.from, + to: envelope.packet.to, + from: envelope.packet.from, + channel: envelope.packet.channel, + packet_id: envelope.packet.id, + channel_id: envelope.channelId, + gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, + latitude: position.latitudeI, + longitude: position.longitudeI, + altitude: position.altitude, + }, + }); + } + + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 4) { + + const user = User.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("NODEINFO_APP", { + from: envelope.packet.from.toString(16), + user: user, + }); + } + + // create or update node in db + try { + await prisma.node.upsert({ + where: { + node_id: envelope.packet.from, + }, + create: { + node_id: envelope.packet.from, + long_name: user.longName, + short_name: user.shortName, + hardware_model: user.hwModel, + is_licensed: user.isLicensed === true, + role: user.role, + }, + update: { + long_name: user.longName, + short_name: user.shortName, + hardware_model: user.hwModel, + is_licensed: user.isLicensed === true, + role: user.role, + }, + }); + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 8) { + + if(!collectWaypoints){ + return; + } + + const waypoint = Waypoint.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("WAYPOINT_APP", { + to: envelope.packet.to.toString(16), + from: envelope.packet.from.toString(16), + waypoint: waypoint, + }); + } + + try { + await prisma.waypoint.create({ + data: { + to: envelope.packet.to, + from: envelope.packet.from, + waypoint_id: waypoint.id, + latitude: waypoint.latitudeI, + longitude: waypoint.longitudeI, + expire: waypoint.expire, + locked_to: waypoint.lockedTo, + name: waypoint.name, + description: waypoint.description, + icon: waypoint.icon, + channel: envelope.packet.channel, + packet_id: envelope.packet.id, + channel_id: envelope.channelId, + gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, + }, + }); + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 71) { + + const neighbourInfo = NeighborInfo.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("NEIGHBORINFO_APP", { + from: envelope.packet.from.toString(16), + neighbour_info: neighbourInfo, + }); + } + + // update node neighbour info in db + try { + await prisma.node.updateMany({ + where: { + node_id: envelope.packet.from, + }, + data: { + neighbours_updated_at: new Date(), + neighbour_broadcast_interval_secs: neighbourInfo.nodeBroadcastIntervalSecs, + neighbours: neighbourInfo.neighbors.map((neighbour) => { + return { + node_id: neighbour.nodeId, + snr: neighbour.snr, + }; + }), + }, + }); + } catch (e) { + console.error(e); + } + + // don't store all neighbour infos, but we want to update the existing node above + if(!collectNeighbourInfo){ + return; + } + + // create neighbour info + try { + await prisma.neighbourInfo.create({ + data: { + node_id: envelope.packet.from, + node_broadcast_interval_secs: neighbourInfo.nodeBroadcastIntervalSecs, + neighbours: neighbourInfo.neighbors.map((neighbour) => { + return { + node_id: neighbour.nodeId, + snr: neighbour.snr, + }; + }), + }, + }); + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 67) { + + const telemetry = Telemetry.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("TELEMETRY_APP", { + from: envelope.packet.from.toString(16), + telemetry: telemetry, + }); + } + + // data to update + const data = {}; + + // handle device metrics + if(telemetry.deviceMetrics){ + + data.battery_level = telemetry.deviceMetrics.batteryLevel !== 0 ? telemetry.deviceMetrics.batteryLevel : null; + data.voltage = telemetry.deviceMetrics.voltage !== 0 ? telemetry.deviceMetrics.voltage : null; + data.channel_utilization = telemetry.deviceMetrics.channelUtilization !== 0 ? telemetry.deviceMetrics.channelUtilization : null; + data.air_util_tx = telemetry.deviceMetrics.airUtilTx !== 0 ? telemetry.deviceMetrics.airUtilTx : null; + data.uptime_seconds = telemetry.deviceMetrics.uptimeSeconds !== 0 ? telemetry.deviceMetrics.uptimeSeconds : null; + + // create device metric + try { + + // find an existing metric with duplicate information created in the last 15 seconds + const existingDuplicateDeviceMetric = await prisma.deviceMetric.findFirst({ + where: { + node_id: envelope.packet.from, + battery_level: data.battery_level, + voltage: data.voltage, + channel_utilization: data.channel_utilization, + air_util_tx: data.air_util_tx, + created_at: { + gte: new Date(Date.now() - 15000), // created in the last 15 seconds + }, + } + }) + + // create metric if no duplicates found + if(!existingDuplicateDeviceMetric){ + await prisma.deviceMetric.create({ + data: { + node_id: envelope.packet.from, + battery_level: data.battery_level, + voltage: data.voltage, + channel_utilization: data.channel_utilization, + air_util_tx: data.air_util_tx, + }, + }); + } + + } catch (e) { + console.error(e); + } + + } + + // handle environment metrics + if(telemetry.environmentMetrics){ + + // get metric values + const temperature = telemetry.environmentMetrics.temperature !== 0 ? telemetry.environmentMetrics.temperature : null; + const relativeHumidity = telemetry.environmentMetrics.relativeHumidity !== 0 ? telemetry.environmentMetrics.relativeHumidity : null; + const barometricPressure = telemetry.environmentMetrics.barometricPressure !== 0 ? telemetry.environmentMetrics.barometricPressure : null; + const gasResistance = telemetry.environmentMetrics.gasResistance !== 0 ? telemetry.environmentMetrics.gasResistance : null; + 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; + data.relative_humidity = relativeHumidity; + data.barometric_pressure = barometricPressure; + + // create environment metric + try { + + // find an existing metric with duplicate information created in the last 15 seconds + const existingDuplicateEnvironmentMetric = await prisma.environmentMetric.findFirst({ + where: { + node_id: envelope.packet.from, + packet_id: envelope.packet.id, + created_at: { + gte: new Date(Date.now() - 15000), // created in the last 15 seconds + }, + } + }) + + // create metric if no duplicates found + if(!existingDuplicateEnvironmentMetric){ + await prisma.environmentMetric.create({ + data: { + node_id: envelope.packet.from, + packet_id: envelope.packet.id, + temperature: temperature, + relative_humidity: relativeHumidity, + barometric_pressure: barometricPressure, + gas_resistance: gasResistance, + voltage: voltage, + current: current, + iaq: iaq, + wind_direction: windDirection, + wind_speed: windSpeed, + wind_gust: windGust, + wind_lull: windLull, + }, + }); + } + + } catch (e) { + console.error(e); + } + + } + + // handle power metrics + if(telemetry.powerMetrics){ + + // get metric values + const ch1Voltage = telemetry.powerMetrics.ch1Voltage !== 0 ? telemetry.powerMetrics.ch1Voltage : null; + const ch1Current = telemetry.powerMetrics.ch1Current !== 0 ? telemetry.powerMetrics.ch1Current : null; + const ch2Voltage = telemetry.powerMetrics.ch2Voltage !== 0 ? telemetry.powerMetrics.ch2Voltage : null; + const ch2Current = telemetry.powerMetrics.ch2Current !== 0 ? telemetry.powerMetrics.ch2Current : null; + const ch3Voltage = telemetry.powerMetrics.ch3Voltage !== 0 ? telemetry.powerMetrics.ch3Voltage : null; + const ch3Current = telemetry.powerMetrics.ch3Current !== 0 ? telemetry.powerMetrics.ch3Current : null; + + // create power metric + try { + + // find an existing metric with duplicate information created in the last 15 seconds + const existingDuplicatePowerMetric = await prisma.powerMetric.findFirst({ + where: { + node_id: envelope.packet.from, + packet_id: envelope.packet.id, + created_at: { + gte: new Date(Date.now() - 15000), // created in the last 15 seconds + }, + } + }) + + // create metric if no duplicates found + if(!existingDuplicatePowerMetric){ + await prisma.powerMetric.create({ + data: { + node_id: envelope.packet.from, + packet_id: envelope.packet.id, + ch1_voltage: ch1Voltage, + ch1_current: ch1Current, + ch2_voltage: ch2Voltage, + ch2_current: ch2Current, + ch3_voltage: ch3Voltage, + ch3_current: ch3Current, + }, + }); + } + + } catch (e) { + console.error(e); + } + + } + + // update node telemetry in db + if(Object.keys(data).length > 0){ + try { + await prisma.node.updateMany({ + where: { + node_id: envelope.packet.from, + }, + data: data, + }); + } catch (e) { + console.error(e); + } + } + + } + + else if(portnum === 70) { + + const routeDiscovery = RouteDiscovery.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("TRACEROUTE_APP", { + to: envelope.packet.to.toString(16), + from: envelope.packet.from.toString(16), + want_response: envelope.packet.decoded.wantResponse, + route_discovery: routeDiscovery, + }); + } + + try { + await prisma.traceRoute.create({ + data: { + to: envelope.packet.to, + from: envelope.packet.from, + want_response: envelope.packet.decoded.wantResponse, + route: routeDiscovery.route, + snr_towards: routeDiscovery.snrTowards, + route_back: routeDiscovery.routeBack, + snr_back: routeDiscovery.snrBack, + channel: envelope.packet.channel, + packet_id: envelope.packet.id, + channel_id: envelope.channelId, + gateway_id: envelope.gatewayId ? convertHexIdToNumericId(envelope.gatewayId) : null, + }, + }); + } catch (e) { + console.error(e); + } + + } + + else if(portnum === 73) { + + const mapReport = MapReport.decode(envelope.packet.decoded.payload); + + if(logKnownPacketTypes) { + console.log("MAP_REPORT_APP", { + from: envelope.packet.from.toString(16), + map_report: mapReport, + }); + } + + // create or update node in db + try { + + // data to set on node + const data = { + long_name: mapReport.longName, + short_name: mapReport.shortName, + hardware_model: mapReport.hwModel, + role: mapReport.role, + latitude: mapReport.latitudeI, + longitude: mapReport.longitudeI, + altitude: mapReport.altitude !== 0 ? mapReport.altitude : null, + firmware_version: mapReport.firmwareVersion, + region: mapReport.region, + modem_preset: mapReport.modemPreset, + has_default_channel: mapReport.hasDefaultChannel, + position_precision: mapReport.positionPrecision, + num_online_local_nodes: mapReport.numOnlineLocalNodes, + position_updated_at: new Date(), + }; + + await prisma.node.upsert({ + where: { + node_id: envelope.packet.from, + }, + create: { + node_id: envelope.packet.from, + ...data, + }, + update: data, + }); + + } catch (e) { + console.error(e); + } + + // don't collect map report history if not enabled, but we still want to update the node above + if(!collectMapReports){ + return; + } + + try { + + // find an existing map with duplicate information created in the last 60 seconds + const existingDuplicateMapReport = await prisma.mapReport.findFirst({ + where: { + node_id: envelope.packet.from, + long_name: mapReport.longName, + short_name: mapReport.shortName, + created_at: { + gte: new Date(Date.now() - 60000), // created in the last 60 seconds + }, + } + }); + + // create map report if no duplicates found + if(!existingDuplicateMapReport){ + await prisma.mapReport.create({ + data: { + node_id: envelope.packet.from, + long_name: mapReport.longName, + short_name: mapReport.shortName, + role: mapReport.role, + hardware_model: mapReport.hwModel, + firmware_version: mapReport.firmwareVersion, + region: mapReport.region, + modem_preset: mapReport.modemPreset, + has_default_channel: mapReport.hasDefaultChannel, + latitude: mapReport.latitudeI, + longitude: mapReport.longitudeI, + altitude: mapReport.altitude, + position_precision: mapReport.positionPrecision, + num_online_local_nodes: mapReport.numOnlineLocalNodes, + }, + }); + } + + } catch (e) { + console.error(e); + } + + } + + else { + if(logUnknownPortnums){ + + // ignore packets we don't want to see for now + if(portnum === undefined // ignore failed to decrypt + || portnum === 0 // ignore UNKNOWN_APP + || portnum === 1 // ignore TEXT_MESSAGE_APP + || portnum === 5 // ignore ROUTING_APP + || portnum === 34 // ignore PAXCOUNTER_APP + || portnum === 65 // ignore STORE_FORWARD_APP + || portnum === 66 // ignore RANGE_TEST_APP + || portnum === 72 // ignore ATAK_PLUGIN + || portnum === 257 // ignore ATAK_FORWARDER + || portnum > 511 // ignore above MAX + ){ + return; + } + + console.log(portnum, envelope); + + } + } + + } catch(e) { + // ignore errors + } +}); diff --git a/mqtt/package-lock.json b/mqtt/package-lock.json new file mode 100644 index 0000000..7b84046 --- /dev/null +++ b/mqtt/package-lock.json @@ -0,0 +1,955 @@ +{ + "name": "mqtt", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mqtt", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@prisma/client": "^5.11.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "mqtt": "^5.11.0", + "protobufjs": "^7.5.0" + }, + "devDependencies": { + "prisma": "^5.10.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz", + "integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.0.tgz", + "integrity": "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "license": "MIT", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/command-line-usage/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/command-line-usage/node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "license": "MIT", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.11.0.tgz", + "integrity": "sha512-VDqfADTNvohwcY02NgxPb7OojIeDrNQ1q62r/DcM+bnIWY8LBi3nMTvdEaFEp6Bu4ejBIpHjJVthUEgnvGLemA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.18", + "@types/ws": "^8.5.14", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.0", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "reinterval": "^1.1.0", + "rfdc": "^1.4.1", + "socks": "^2.8.3", + "split2": "^4.2.0", + "worker-timers": "^7.1.8", + "ws": "^8.18.0" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.0.tgz", + "integrity": "sha512-Z2E/kOY1QjoMlCytmexzYfDm/w5fKAiRwpSzGtdnXW1zC88Z2yXazHHrOtwCzn+7wSxyE8PYM4rvVcMphF9sOA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/table-layout/node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, + "node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/mqtt/package.json b/mqtt/package.json new file mode 100644 index 0000000..454daa7 --- /dev/null +++ b/mqtt/package.json @@ -0,0 +1,18 @@ +{ + "name": "mqtt", + "version": "1.0.0", + "main": "index.js", + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@prisma/client": "^5.11.0", + "command-line-args": "^5.2.1", + "command-line-usage": "^7.0.1", + "mqtt": "^5.11.0", + "protobufjs": "^7.5.0" + }, + "devDependencies": { + "prisma": "^5.10.2" + } +} diff --git a/mqtt/utils/node_id_util.js b/mqtt/utils/node_id_util.js new file mode 100644 index 0000000..5397df1 --- /dev/null +++ b/mqtt/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/mqtt/utils/node_id_util.test.js b/mqtt/utils/node_id_util.test.js new file mode 100644 index 0000000..494c3bb --- /dev/null +++ b/mqtt/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)); +}); diff --git a/mqtt/utils/position_util.js b/mqtt/utils/position_util.js new file mode 100644 index 0000000..6824b0b --- /dev/null +++ b/mqtt/utils/position_util.js @@ -0,0 +1,66 @@ +class PositionUtil { + + /** + * Obfuscates the provided latitude or longitude down to the provided precision in bits. + * This is based on the same logic in the official meshtastic firmware. + * https://github.com/meshtastic/firmware/blob/0a93261c0646f93aea518cc0599e547e9dc0e997/src/modules/PositionModule.cpp#L187 + */ + static setPositionPrecision(latitudeOrLongitudeInteger, precision) { + + // check if we should use the provided precision + if(precision > 0 && precision < 32){ + + // apply bitmask to reduce precision of position to wanted bits + latitudeOrLongitudeInteger = latitudeOrLongitudeInteger & (0xFFFFFFFF << (32 - precision)); + + // we want the imprecise position to be the middle of the possible location + latitudeOrLongitudeInteger += (1 << (31 - precision)); + + } + + return latitudeOrLongitudeInteger; + + } + + /** + * Truncates the provided latitude or longitude to a maximum of x decimal places + * e.g: 12.3456789 with 2 decimal places would be 12.34 + * @param latitudeOrLongitudeString e.g: 12.3456789 + * @param numberOfDecimalPlaces how many decimal places to allow in the result + * @returns {*|string|null} + */ + static truncateDecimalPlaces(latitudeOrLongitudeString, numberOfDecimalPlaces) { + + // ensure value not null + if(latitudeOrLongitudeString == null){ + return null; + } + + // split into left and right side of decimal point + // e.g: -12.3456789 -> [-12, 3456789] + var [ leftOfDecimalPoint, rightOfDecimalPoint ] = latitudeOrLongitudeString.split("."); + + // check if decimal places available + if(rightOfDecimalPoint != null){ + + // truncate decimal places to desired length + rightOfDecimalPoint = rightOfDecimalPoint.substring(0, numberOfDecimalPlaces); + + // return modified position with decimal places, if available + if(rightOfDecimalPoint.length > 0){ + return [ leftOfDecimalPoint, rightOfDecimalPoint ].join("."); + } + + // no decimal places available anymore, return left side without dot + return leftOfDecimalPoint; + + } + + // decimal places not available, return position as is + return latitudeOrLongitudeString; + + } + +} + +module.exports = PositionUtil; diff --git a/mqtt/utils/position_util.test.js b/mqtt/utils/position_util.test.js new file mode 100644 index 0000000..f00d3bd --- /dev/null +++ b/mqtt/utils/position_util.test.js @@ -0,0 +1,47 @@ +const PositionUtil = require("./position_util"); + +test('can truncate string position to provided decimal places', () => { + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 0)).toBe("12"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 1)).toBe("12.3"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 2)).toBe("12.34"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 3)).toBe("12.345"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 4)).toBe("12.3456"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 5)).toBe("12.34567"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 6)).toBe("12.345678"); + expect(PositionUtil.truncateDecimalPlaces("12.3456789", 7)).toBe("12.3456789"); + expect(PositionUtil.truncateDecimalPlaces("12.3", 7)).toBe("12.3"); + expect(PositionUtil.truncateDecimalPlaces(null, 2)).toBe(null); + expect(PositionUtil.truncateDecimalPlaces("", 2)).toBe(""); + expect(PositionUtil.truncateDecimalPlaces("12", 2)).toBe("12"); + expect(PositionUtil.truncateDecimalPlaces("123", 2)).toBe("123"); + expect(PositionUtil.truncateDecimalPlaces("1234.", 2)).toBe("1234"); +}); + +test('can set integer position precision to provided bits', () => { + + // these tests are using the auckland sky tower position + // auckland sky tower: -36.84844007222091, 174.76221115261924 + // the outputs we are expecting, are the same values returned by the code in the meshtastic firmware + // https://github.com/meshtastic/firmware/blob/0a93261c0646f93aea518cc0599e547e9dc0e997/src/modules/PositionModule.cpp#L187 + + // set precision to 32 bits (within 0 meters) + // -36.8484400, 174.7622111 -> -36.8484400, 174.7622111 + expect(PositionUtil.setPositionPrecision(-368484400, 32)).toBe(-368484400); + expect(PositionUtil.setPositionPrecision(1747622111, 32)).toBe(1747622111); + + // set precision to 16 bits (within ~364 meters) + // -36.8484400, 174.7622111 -> -36.8476160, 174.7615744 + expect(PositionUtil.setPositionPrecision(-368484400, 16)).toBe(-368476160); + expect(PositionUtil.setPositionPrecision(1747622111, 16)).toBe(1747615744); + + // set precision to 13 bits (within ~2.9 kilometers) + // -36.8484400, 174.7622111 -> -36.8312320, 174.7714048 + expect(PositionUtil.setPositionPrecision(-368484400, 13)).toBe(-368312320); + expect(PositionUtil.setPositionPrecision(1747622111, 13)).toBe(1747714048); + + // set precision to 11 bits (within ~11.6 kilometers) + // -36.8484400, 174.7622111 -> -36.8050176, 174.7976192 + expect(PositionUtil.setPositionPrecision(-368484400, 11)).toBe(-368050176); + expect(PositionUtil.setPositionPrecision(1747622111, 11)).toBe(1747976192); + +});