import path from "path"; import { fileURLToPath } from 'url'; import express from "express"; import compression from "compression"; import commandLineArgs from "command-line-args"; import commandLineUsage from "command-line-usage"; // protobuf imports import { Mesh, Config } from "@meshtastic/protobufs"; // create prisma db client import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); // return big ints as string when using JSON.stringify BigInt.prototype.toJSON = function() { return this.toString(); } const optionsList = [ { name: 'help', alias: 'h', type: Boolean, description: 'Display this usage guide.' }, { name: "port", type: Number, description: "Port to serve web ui and api from.", }, ]; // parse command line args const options = commandLineArgs(optionsList); // show help if(options.help){ const usage = commandLineUsage([ { header: 'Meshtastic Map', content: 'A map of all Meshtastic nodes heard via MQTT.', }, { header: 'Options', optionList: optionsList, }, ]); console.log(usage); process.exit(1); } // get options and fallback to default values const port = options["port"] ?? 8080; // load protobuf enums const HardwareModel = Mesh.HardwareModel; const Role = Config.Config_DeviceConfig_Role; const RegionCode = Config.Config_LoRaConfig_RegionCode; const ModemPreset = Config.Config_LoRaConfig_ModemPreset; // appends extra info for node objects returned from api function formatNodeInfo(node) { return { ...node, node_id_hex: "!" + node.node_id.toString(16), hardware_model_name: HardwareModel[node.hardware_model] ?? null, role_name: Role[node.role] ?? null, region_name: RegionCode[node.region] ?? null, modem_preset_name: ModemPreset[node.modem_preset] ?? null, }; } const app = express(); // enable compression app.use(compression()); // serve files inside the public folder from / const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); app.use('/', express.static(path.join(__dirname, 'public'))); app.get('/', async (req, res) => { res.sendFile(path.join(__dirname, 'public/index.html')); }); app.get('/api', async (req, res) => { const links = [ { "path": "/api", "description": "This page", }, { "path": "/api/v1/nodes", "description": "All meshtastic nodes", "params": { "role": "Filter by role", "hardware_model": "Filter by hardware model", }, }, { "path": "/api/v1/nodes/:nodeId", "description": "A specific meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/device-metrics", "description": "Device metrics for a meshtastic node", "params": { "count": "How many results to return", "time_from": "Only include metrics created after this unix timestamp (milliseconds)", "time_to": "Only include metrics created before this unix timestamp (milliseconds)", }, }, { "path": "/api/v1/nodes/:nodeId/environment-metrics", "description": "Environment metrics for a meshtastic node", "params": { "count": "How many results to return", "time_from": "Only include metrics created after this unix timestamp (milliseconds)", "time_to": "Only include metrics created before this unix timestamp (milliseconds)", }, }, { "path": "/api/v1/nodes/:nodeId/power-metrics", "description": "Power metrics for a meshtastic node", "params": { "count": "How many results to return", "time_from": "Only include metrics created after this unix timestamp (milliseconds)", "time_to": "Only include metrics created before this unix timestamp (milliseconds)", }, }, { "path": "/api/v1/nodes/:nodeId/neighbours", "description": "Neighbours for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/traceroutes", "description": "Trace routes for a meshtastic node", }, { "path": "/api/v1/nodes/:nodeId/position-history", "description": "Position history for a meshtastic node", "params": { "time_from": "Only include positions created after this unix timestamp (milliseconds)", "time_to": "Only include positions created before this unix timestamp (milliseconds)", }, }, { "path": "/api/v1/stats/hardware-models", "description": "Database statistics about known hardware models", }, { "path": "/api/v1/text-messages", "description": "Text messages", "params": { "to": "Only include messages to this node id", "from": "Only include messages from this node id", "channel_id": "Only include messages for this channel id", "gateway_id": "Only include messages gated to mqtt by this node id", "last_id": "Only include messages before or after this id, based on results order", "count": "How many results to return", "order": "Order to return results in: asc, desc", }, }, { "path": "/api/v1/text-messages/embed", "description": "Text messages rendered as an embeddable HTML page.", }, { "path": "/api/v1/waypoints", "description": "Waypoints", }, ]; const linksHtml = links.map((link) => { var line = `
  • `; line += `${link.path} - ${link.description}`; line += ``; return line; }).join(""); res.send(`API Docs
    `); }); app.get('/api/v1/nodes', async (req, res) => { try { // get query params const role = req.query.role ? parseInt(req.query.role) : undefined; const hardwareModel = req.query.hardware_model ? parseInt(req.query.hardware_model) : undefined; // get nodes from db const nodes = await prisma.node.findMany({ where: { role: role, hardware_model: hardwareModel, }, }); const nodesWithInfo = []; for(const node of nodes){ nodesWithInfo.push(formatNodeInfo(node)); } res.json({ nodes: nodesWithInfo, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } res.json({ node: formatNodeInfo(node), }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/device-metrics', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); const count = req.query.count ? parseInt(req.query.count) : undefined; const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined; const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined; // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get latest device metrics const deviceMetrics = await prisma.deviceMetric.findMany({ where: { node_id: node.node_id, created_at: { gte: timeFrom ? new Date(timeFrom) : undefined, lte: timeTo ? new Date(timeTo) : undefined, }, }, orderBy: { id: 'desc', }, take: count, }); res.json({ device_metrics: deviceMetrics, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/environment-metrics', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); const count = req.query.count ? parseInt(req.query.count) : undefined; const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined; const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined; // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get latest environment metrics const environmentMetrics = await prisma.environmentMetric.findMany({ where: { node_id: node.node_id, created_at: { gte: timeFrom ? new Date(timeFrom) : undefined, lte: timeTo ? new Date(timeTo) : undefined, }, }, orderBy: { id: 'desc', }, take: count, }); res.json({ environment_metrics: environmentMetrics, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/power-metrics', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); const count = req.query.count ? parseInt(req.query.count) : undefined; const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : undefined; const timeTo = req.query.time_to ? parseInt(req.query.time_to) : undefined; // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get latest power metrics const powerMetrics = await prisma.powerMetric.findMany({ where: { node_id: node.node_id, created_at: { gte: timeFrom ? new Date(timeFrom) : undefined, lte: timeTo ? new Date(timeTo) : undefined, }, }, orderBy: { id: 'desc', }, take: count, }); res.json({ power_metrics: powerMetrics, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/mqtt-metrics', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get mqtt topics published to by this node const queryResult = await prisma.$queryRaw`select mqtt_topic, count(*) as packet_count, max(created_at) as last_packet_at from service_envelopes where gateway_id = ${nodeId} group by mqtt_topic order by packet_count desc;`; res.json({ mqtt_metrics: queryResult, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/neighbours', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get nodes from db that have this node as a neighbour const nodesThatHeardUs = await prisma.node.findMany({ where: { neighbours: { array_contains: { node_id: Number(nodeId), }, }, }, }); res.json({ nodes_that_we_heard: node.neighbours.map((neighbour) => { return { ...neighbour, updated_at: node.neighbours_updated_at, }; }), nodes_that_heard_us: nodesThatHeardUs.map((nodeThatHeardUs) => { const neighbourInfo = nodeThatHeardUs.neighbours.find((neighbour) => neighbour.node_id.toString() === node.node_id.toString()); return { node_id: Number(nodeThatHeardUs.node_id), snr: neighbourInfo.snr, updated_at: nodeThatHeardUs.neighbours_updated_at, }; }), }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/traceroutes', async (req, res) => { try { const nodeId = parseInt(req.params.nodeId); const count = req.query.count ? parseInt(req.query.count) : 10; // can't set to null because of $queryRaw // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } // get latest traceroutes // We want replies where want_response is false and it will be "to" the // requester. const traceroutes = await prisma.$queryRaw`SELECT * FROM traceroutes WHERE want_response = false and \`to\` = ${node.node_id} and gateway_id is not null order by id desc limit ${count}`; res.json({ traceroutes: traceroutes.map((trace) => { // ensure route is json array if(typeof(trace.route) === "string"){ trace.route = JSON.parse(trace.route); } // ensure route_back is json array if(typeof(trace.route_back) === "string"){ trace.route_back = JSON.parse(trace.route_back); } // ensure snr_towards is json array if(typeof(trace.snr_towards) === "string"){ trace.snr_towards = JSON.parse(trace.snr_towards); } // ensure snr_back is json array if(typeof(trace.snr_back) === "string"){ trace.snr_back = JSON.parse(trace.snr_back); } return trace; }), }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/nodes/:nodeId/position-history', async (req, res) => { try { // defaults const nowInMilliseconds = new Date().getTime(); const oneHourAgoInMilliseconds = new Date().getTime() - (3600 * 1000); // get request params const nodeId = parseInt(req.params.nodeId); const timeFrom = req.query.time_from ? parseInt(req.query.time_from) : oneHourAgoInMilliseconds; const timeTo = req.query.time_to ? parseInt(req.query.time_to) : nowInMilliseconds; // find node const node = await prisma.node.findFirst({ where: { node_id: nodeId, }, }); // make sure node exists if(!node){ res.status(404).json({ message: "Not Found", }); return; } const positions = await prisma.position.findMany({ where: { node_id: nodeId, created_at: { gte: new Date(timeFrom), lte: new Date(timeTo), }, } }); const mapReports = await prisma.mapReport.findMany({ where: { node_id: nodeId, created_at: { gte: new Date(timeFrom), lte: new Date(timeTo), }, } }); const positionHistory = [] positions.forEach((position) => { positionHistory.push({ id: position.id, node_id: position.node_id, type: "position", latitude: position.latitude, longitude: position.longitude, altitude: position.altitude, gateway_id: position.gateway_id, channel_id: position.channel_id, created_at: position.created_at, }); }); mapReports.forEach((mapReport) => { positionHistory.push({ node_id: mapReport.node_id, type: "map_report", latitude: mapReport.latitude, longitude: mapReport.longitude, altitude: mapReport.altitude, created_at: mapReport.created_at, }); }); // sort oldest to newest positionHistory.sort((a, b) => a.created_at - b.created_at); res.json({ position_history: positionHistory, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/stats/hardware-models', async (req, res) => { try { // get nodes from db const results = await prisma.node.groupBy({ by: ['hardware_model'], orderBy: { _count: { hardware_model: 'desc', }, }, _count: { hardware_model: true, }, }); const hardwareModelStats = results.map((result) => { return { count: result._count.hardware_model, hardware_model: result.hardware_model, hardware_model_name: HardwareModel[result.hardware_model] ?? "UNKNOWN", }; }); res.json({ hardware_model_stats: hardwareModelStats, }); } catch(err) { console.error(err); res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/text-messages', async (req, res) => { try { // get query params const to = req.query.to ?? undefined; const from = req.query.from ?? undefined; const channelId = req.query.channel_id ?? undefined; const gatewayId = req.query.gateway_id ?? undefined; const directMessageNodeIds = req.query.direct_message_node_ids?.split(",") ?? undefined; const lastId = req.query.last_id ? parseInt(req.query.last_id) : undefined; const count = req.query.count ? parseInt(req.query.count) : 50; const order = req.query.order ?? "asc"; // if direct message node ids are provided, there should be exactly two node ids if(directMessageNodeIds !== undefined && directMessageNodeIds.length !== 2){ res.status(400).json({ message: "direct_message_node_ids requires 2 node ids separated by a comma.", }); return; } // default where clauses that should always be used for filtering var where = { channel_id: channelId, gateway_id: gatewayId, // when ordered oldest to newest (asc), only get records after last id // when ordered newest to oldest (desc), only get records before last id id: order === "asc" ? { gt: lastId, } : { lt: lastId, }, }; // if direct message node ids are provided, we expect exactly 2 node ids if(directMessageNodeIds !== undefined && directMessageNodeIds.length === 2){ // filter message by "to -> from" or "from -> to" const [firstNodeId, secondNodeId] = directMessageNodeIds; where = { AND: where, OR: [ { to: firstNodeId, from: secondNodeId, }, { to: secondNodeId, from: firstNodeId, }, ], }; } else { // filter by to and from where = { ...where, to: to, from: from, }; } // get text messages from db const textMessages = await prisma.textMessage.findMany({ where: where, orderBy: { id: order, }, take: count, }); res.json({ text_messages: textMessages, }); } catch(err) { res.status(500).json({ message: "Something went wrong, try again later.", }); } }); app.get('/api/v1/text-messages/embed', async (req, res) => { res.sendFile(path.join(__dirname, 'public/text-messages-embed.html')); }); app.get('/api/v1/waypoints', async (req, res) => { try { // get waypoints from db const waypoints = await prisma.waypoint.findMany({ orderBy: { id: 'desc', }, }); // ensure we only have the latest unique waypoints // since ordered by newest first, older entries will be ignored const uniqueWaypoints = []; for(const waypoint of waypoints){ // skip if we already have a newer entry for this waypoint if(uniqueWaypoints.find((w) => w.from === waypoint.from && w.waypoint_id === waypoint.waypoint_id)){ continue; } // first time seeing this waypoint, add to unique list uniqueWaypoints.push(waypoint); } // we only want waypoints that haven't expired yet const nonExpiredWayPoints = uniqueWaypoints.filter((waypoint) => { const nowInSeconds = Math.floor(Date.now() / 1000); return waypoint.expire >= nowInSeconds; }); res.json({ waypoints: nonExpiredWayPoints, }); } catch(err) { res.status(500).json({ message: "Something went wrong, try again later.", }); } }); // start express server const listener = app.listen(port, () => { const port = listener.address().port; console.log(`Server running at http://127.0.0.1:${port}`); });