├── frontend ├── .nvmrc ├── public │ ├── .gitkeep │ └── images │ │ └── icons │ │ ├── node.svg │ │ ├── chat.svg │ │ ├── route2.svg │ │ ├── stats.svg │ │ ├── map.svg │ │ ├── route.svg │ │ ├── telemetry.svg │ │ └── neighbors.svg ├── .yarnrc.yml ├── src │ ├── vite-env.d.ts │ ├── index.css │ ├── pages │ │ ├── Home.tsx │ │ ├── Chat.tsx │ │ └── Nodes.tsx │ ├── hooks.ts │ ├── router.tsx │ ├── main.tsx │ ├── store.ts │ ├── utils │ │ └── getDistanceBetweenPoints.ts │ ├── slices │ │ ├── appSlice.ts │ │ └── apiSlice.ts │ └── types.ts ├── .yarn │ ├── install-state.gz │ └── sdks │ │ ├── integrations.yml │ │ ├── typescript │ │ ├── package.json │ │ ├── lib │ │ │ ├── typescript.js │ │ │ └── tsc.js │ │ └── bin │ │ │ ├── tsc │ │ │ └── tsserver │ │ └── eslint │ │ ├── package.json │ │ ├── lib │ │ ├── api.js │ │ └── unsupported-api.js │ │ └── bin │ │ └── eslint.js ├── postcss.config.js ├── .env.sample ├── tsconfig.node.json ├── tailwind.config.ts ├── .gitignore ├── vite.config.ts ├── README.md ├── tsconfig.json ├── .eslintrc.cjs ├── package.json └── index.html ├── storage ├── db │ └── postgres.py └── file.py ├── .dockerignore ├── .tool-versions ├── input.css ├── meshinfo1.png ├── meshinfo2.png ├── meshinfo3.png ├── meshinfo4.png ├── meshinfo5.png ├── CONTRIBUTING.md ├── mosquitto └── config │ ├── mosquitto.acl │ ├── mosquitto.passwd │ └── mosquitto.conf ├── run.sh ├── public └── images │ ├── hardware │ ├── TBEAM.png │ ├── T_DECK.png │ ├── T_ECHO.png │ ├── HELTEC_V3.png │ ├── RAK11310.png │ ├── RAK4631.png │ ├── RPI_PICO.png │ ├── XIAO_BLE.png │ ├── HELTEC_HT62.png │ ├── HELTEC_V2_0.png │ ├── HELTEC_V2_1.png │ ├── PRIVATE_HW.png │ ├── RP2040_LORA.png │ ├── TLORA_T3_S3.png │ ├── T_WATCH_S3.png │ ├── HELTEC_WSL_V3.png │ ├── NANO_G2_ULTRA.png │ ├── TLORA_V2_1_1P6.png │ ├── NANO_G1_EXPLORER.png │ ├── NRF52_PROMICRO_DIY.png │ ├── HELTEC_WIRELESS_PAPER.png │ ├── LILYGO_TBEAM_S3_CORE.png │ ├── HELTEC_WIRELESS_TRACKER.png │ ├── HELTEC_WIRELESS_PAPER_V1_0.png │ └── HELTEC_WIRELESS_TRACKER_V1_0.png │ └── icons │ ├── down.svg │ ├── up.svg │ ├── voltage.svg │ ├── node.svg │ ├── chat.svg │ ├── battery.svg │ ├── route2.svg │ ├── stats.svg │ ├── temperature.svg │ ├── pressure.svg │ ├── resistance.svg │ ├── map.svg │ ├── humidity.svg │ ├── relative-humidity.svg │ ├── route.svg │ ├── telemetry.svg │ ├── neighbors.svg │ └── current.svg ├── .vscode ├── extensions.json └── settings.json ├── .gitattributes ├── templates ├── api │ ├── index.html.j2 │ └── layout.html.j2 └── static │ ├── routes.html.j2 │ ├── index.html.j2 │ ├── mesh_log.html.j2 │ ├── mqtt_log.html.j2 │ ├── graph.html.j2 │ ├── stats.html.j2 │ ├── traceroutes.html.j2 │ ├── layout-map.html.j2 │ ├── chat.html.j2 │ ├── neighbors.html.j2 │ ├── layout.html.j2 │ ├── telemetry.html.j2 │ └── nodes.html.j2 ├── version.json ├── requirements.txt ├── .github ├── dependabot.yml ├── FUNDING.yml └── workflows │ └── docker.yml ├── .gitignore ├── Caddyfile ├── Caddyfile.sample ├── Caddyfile.dev ├── scripts ├── docker-build.sh ├── release.sh └── version.sh ├── API.md ├── geo.py ├── config.py ├── models └── node.py ├── Dockerfile ├── encoders.py ├── docker-compose.yml ├── docker-compose-dev.yml ├── data_renderer.py ├── utils.py ├── banner ├── asyncio_helper.py ├── main.py ├── bot ├── discord.py └── cogs │ └── main_commands.py ├── config.json.sample ├── postgres └── sql │ └── meshinfo.sql ├── meshtastic_support.py ├── CODE_OF_CONDUCT.md ├── README.md └── api └── api.py /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 2 | -------------------------------------------------------------------------------- /frontend/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/db/postgres.py: -------------------------------------------------------------------------------- 1 | we 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | caddy 2 | output 3 | postgres 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.12.4 2 | nodejs 20.11.1 3 | -------------------------------------------------------------------------------- /frontend/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /meshinfo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/meshinfo1.png -------------------------------------------------------------------------------- /meshinfo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/meshinfo2.png -------------------------------------------------------------------------------- /meshinfo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/meshinfo3.png -------------------------------------------------------------------------------- /meshinfo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/meshinfo4.png -------------------------------------------------------------------------------- /meshinfo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/meshinfo5.png -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | TODO: Someone needs to make this. 4 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /mosquitto/config/mosquitto.acl: -------------------------------------------------------------------------------- 1 | topic read msh/# 2 | 3 | user meshinfo 4 | topic readwrite msh/# 5 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf /app/spa/* 4 | cp -rf /app/dist/* /app/spa 5 | python main.py 6 | -------------------------------------------------------------------------------- /frontend/.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/frontend/.yarn/install-state.gz -------------------------------------------------------------------------------- /public/images/hardware/TBEAM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/TBEAM.png -------------------------------------------------------------------------------- /public/images/hardware/T_DECK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/T_DECK.png -------------------------------------------------------------------------------- /public/images/hardware/T_ECHO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/T_ECHO.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_V3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_V3.png -------------------------------------------------------------------------------- /public/images/hardware/RAK11310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/RAK11310.png -------------------------------------------------------------------------------- /public/images/hardware/RAK4631.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/RAK4631.png -------------------------------------------------------------------------------- /public/images/hardware/RPI_PICO.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/RPI_PICO.png -------------------------------------------------------------------------------- /public/images/hardware/XIAO_BLE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/XIAO_BLE.png -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_HT62.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_HT62.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_V2_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_V2_0.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_V2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_V2_1.png -------------------------------------------------------------------------------- /public/images/hardware/PRIVATE_HW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/PRIVATE_HW.png -------------------------------------------------------------------------------- /public/images/hardware/RP2040_LORA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/RP2040_LORA.png -------------------------------------------------------------------------------- /public/images/hardware/TLORA_T3_S3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/TLORA_T3_S3.png -------------------------------------------------------------------------------- /public/images/hardware/T_WATCH_S3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/T_WATCH_S3.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_WSL_V3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_WSL_V3.png -------------------------------------------------------------------------------- /public/images/hardware/NANO_G2_ULTRA.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/NANO_G2_ULTRA.png -------------------------------------------------------------------------------- /public/images/hardware/TLORA_V2_1_1P6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/TLORA_V2_1_1P6.png -------------------------------------------------------------------------------- /public/images/hardware/NANO_G1_EXPLORER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/NANO_G1_EXPLORER.png -------------------------------------------------------------------------------- /public/images/hardware/NRF52_PROMICRO_DIY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/NRF52_PROMICRO_DIY.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_WIRELESS_PAPER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_WIRELESS_PAPER.png -------------------------------------------------------------------------------- /public/images/hardware/LILYGO_TBEAM_S3_CORE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/LILYGO_TBEAM_S3_CORE.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_WIRELESS_TRACKER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_WIRELESS_TRACKER.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "csstools.postcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /mosquitto/config/mosquitto.passwd: -------------------------------------------------------------------------------- 1 | meshinfo:$7$100$GfT3izwgglPkWSt0$z+aAv9FNTEUgpMg6VxQjBTFQcwGTk1z/Vbhutvrd+0pOfR45k7rZIlN7gMX9Jra+IkUUs0QbsvJ/+u8wl4Dx9w== 2 | -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_WIRELESS_PAPER_V1_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_WIRELESS_PAPER_V1_0.png -------------------------------------------------------------------------------- /public/images/hardware/HELTEC_WIRELESS_TRACKER_V1_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elements/meshinfo/develop/public/images/hardware/HELTEC_WIRELESS_TRACKER_V1_0.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | VITE_MESH_DESCRIPTION=Serving Meshtastic to the Central Valley and surrounding areas. 2 | VITE_MESH_URL=https://sacvalleymesh.com 3 | VITE_MESH_SERVER_NODE=!4355f528 -------------------------------------------------------------------------------- /templates/api/index.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "layout.html.j2" %} 2 | 3 | {% block title %}Welcome!{% endblock %} 4 | 5 | {% block content %} 6 |

Welcome!

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { "version": "0.0.298", "major": 0, "minor": 0, "patch": 298, "build_date_iso_8601": "2024-08-03T00:00:00-07:00", "git_sha": "84e255ddf357386afc98f8217bb52c8515ff9072" } 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiomqtt 2 | aiohttp 3 | jinja2 4 | scipy 5 | python-dotenv 6 | fastapi 7 | discord.py 8 | requests 9 | meshtastic 10 | cryptography 11 | protobuf 12 | uvicorn 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.4.5-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "tsc": "./bin/tsc", 8 | "tsserver": "./bin/tsserver" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | **/__pycache__ 7 | .trunk/ 8 | node_modules/ 9 | .github_pat 10 | .env 11 | 12 | backups/* 13 | caddy 14 | output/* 15 | mosquitto/data/* 16 | postgres/data/* 17 | config.json 18 | spa 19 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | export const Home = () => ( 2 | <> 3 |

Welcome TEST!

4 |

5 | This is a site that shows data about the SVM by KE-R (!4355f528). 6 |

7 |

Last updated: {new Date().toString()}

8 | 9 | ); 10 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | } 4 | 5 | YOUR_FQDN_OR_HOSTNAME { 6 | root * /srv 7 | encode gzip 8 | file_server 9 | tls YOUR_EMAIL_ADDRESS 10 | log { 11 | output file /var/log/caddy.log 12 | } 13 | handle_path /api/* { 14 | reverse_proxy meshinfo:9000 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Caddyfile.sample: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | } 4 | 5 | YOUR_FQDN_OR_HOSTNAME { 6 | root * /srv 7 | encode gzip 8 | file_server 9 | tls YOUR_EMAIL_ADDRESS 10 | log { 11 | output file /var/log/caddy.log 12 | } 13 | handle_path /api/* { 14 | reverse_proxy meshinfo:9000 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | corePlugins: { 10 | preflight: false, 11 | }, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /storage/file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # functions to save and load data to files (json, html, etc) 4 | 5 | class JSONFileStorage(): 6 | def chat_load(): 7 | pass 8 | 9 | def chat_save(): 10 | pass 11 | 12 | def nodes_load(): 13 | pass 14 | 15 | def nodes_save(): 16 | pass 17 | -------------------------------------------------------------------------------- /Caddyfile.dev: -------------------------------------------------------------------------------- 1 | { 2 | debug 3 | } 4 | 5 | localhost { 6 | encode gzip 7 | handle_path /api/* { 8 | reverse_proxy meshinfo:9000 9 | } 10 | handle_path /next/* { 11 | root * /srv/next 12 | try_files {path} /index.html 13 | file_server 14 | } 15 | handle /* { 16 | root * /srv 17 | file_server 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # build 4 | 5 | # set version from args if not, exit 6 | if [ -z "$1" ] 7 | then 8 | echo "No version supplied (e.g. 1.0.0)" 9 | exit 1 10 | fi 11 | 12 | REPO=meshaddicts/meshinfo 13 | VERSION=$1 14 | 15 | docker build -t $REPO:$VERSION --platform=linux/amd64 . 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.yarn": true, 4 | "**/.pnp.*": true 5 | }, 6 | "eslint.nodePath": "frontend/.yarn/sdks", 7 | "typescript.tsdk": "frontend/.yarn/sdks/typescript/lib", 8 | "typescript.enablePromptUseWorkspaceTsdk": true 9 | "python.analysis.typeCheckingMode": "basic" 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from "react-redux"; 2 | import type { AppDispatch, RootState } from "./store"; 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = useDispatch.withTypes(); 6 | export const useAppSelector = useSelector.withTypes(); 7 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.57.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "eslint": "./bin/eslint.js" 8 | }, 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": "./lib/api.js", 12 | "./use-at-your-own-risk": "./lib/unsupported-api.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /public/images/icons/down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/images/icons/up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | proxy: { 9 | "/api": { 10 | target: "http://localhost:8080", 11 | changeOrigin: true, 12 | rewrite: (path) => path.replace(/^\/api/, ""), 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # git tag, docker build and push 4 | 5 | # set version from args if not, exit 6 | if [ -z "$1" ] 7 | then 8 | echo "No version supplied (e.g. 1.0.0)" 9 | exit 1 10 | fi 11 | 12 | REPO=meshaddicts/meshinfo 13 | VERSION=$1 14 | 15 | git tag -a $VERSION -m "Version $VERSION" && git push --tags 16 | docker build -t ghcr.io/$REPO:$VERSION --platform=linux/amd64 . && docker push ghcr.io/$REPO:$VERSION 17 | -------------------------------------------------------------------------------- /templates/static/routes.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Routes{% endblock %} 4 | 5 | {% block content %} 6 |

Routes

7 |

8 | Route paths of the mesh as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 9 |

10 |

Last updated: {{ timestamp.astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }}

11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /frontend/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from "react-router-dom"; 2 | 3 | import { Chat } from "./pages/Chat"; 4 | import { Home } from "./pages/Home"; 5 | import { Map } from "./pages/Map"; 6 | import { Nodes } from "./pages/Nodes"; 7 | 8 | export const router = createBrowserRouter([ 9 | { path: "/next", element: }, 10 | { path: "/next/chat", element: }, 11 | { path: "/next/map", element: }, 12 | { path: "/next/nodes", element: }, 13 | ]); 14 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # MeshInfo API Documentation 2 | 3 | This document describes the MeshInfo instance API. The primary purpose of the API is to allow the UI to retrieve 4 | information about the known mesh. You can use this to query against your instance if you would like to create 5 | integrations. 6 | 7 | ## Endpoint Overview 8 | 9 | * `/v1/nodes` 10 | * `/v1/nodes/:id` 11 | * `/v1/nodes/:id/telemetry` 12 | * `/v1/nodes/:id/text` 13 | * `/v1/server/config` 14 | 15 | ## Endpoint Details 16 | 17 | Coming soon. 18 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom/client"; 5 | import { Provider } from "react-redux"; 6 | import { RouterProvider } from "react-router-dom"; 7 | 8 | import { router } from "./router"; 9 | import { store } from "./store"; 10 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render( 12 | 13 | 14 | 15 | 16 | , 17 | ); 18 | -------------------------------------------------------------------------------- /templates/static/index.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Welcome!{% endblock %} 4 | 5 | {% block content %} 6 |

Welcome!

7 |

8 | This is a site that shows data about the {{ config['server']['name'] }} by 9 | {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 10 |

11 |

Last updated: {{ timestamp.astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }}

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /public/images/icons/voltage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /public/images/icons/node.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/images/icons/node.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript your application uses 20 | module.exports = absRequire(`typescript`); 21 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /geo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from math import asin, cos, radians, sin, sqrt 4 | 5 | 6 | def distance_between_two_points(lat1, lon1, lat2, lon2): 7 | # Convert latitude and longitude to radians 8 | lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) 9 | 10 | # Calculate the differences in longitude and latitude 11 | dlon = lon2 - lon1 12 | dlat = lat2 - lat1 13 | 14 | # Calculate the Haversine formula 15 | a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 16 | c = 2 * asin(sqrt(a)) 17 | radius = 6371 # Radius of Earth in kilometers 18 | distance = radius * c # Distance in kilometers 19 | 20 | return distance 21 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # meshinfo frontend 2 | 3 | ## Usage 4 | 5 | - [Install yarn](https://yarnpkg.com/getting-started/install) 6 | - Install dependencies: 7 | 8 | ```bash 9 | yarn 10 | ``` 11 | 12 | - Start dev server 13 | 14 | ```bash 15 | yarn dev 16 | ``` 17 | 18 | ## Development 19 | 20 | If you're having issues with your editor picking up types in TypeScript, follow the [yarn editor sdk docs](https://yarnpkg.com/getting-started/editor-sdks). This repo already includes Visual Studio Code sdks, but they may need to be augmented or regenerated. 21 | If you're using VSCode, make sure you install the recommended extensions. More info can be found in [the yarn docs](https://yarnpkg.com/getting-started/editor-sdks#vscode) 22 | -------------------------------------------------------------------------------- /frontend/.yarn/sdks/eslint/lib/unsupported-api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/use-at-your-own-risk 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/use-at-your-own-risk your application uses 20 | module.exports = absRequire(`eslint/use-at-your-own-risk`); 21 | -------------------------------------------------------------------------------- /frontend/src/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { appSlice } from "./slices/appSlice"; 3 | import { apiSlice } from "./slices/apiSlice"; 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | [appSlice.reducerPath]: appSlice.reducer, 8 | [apiSlice.reducerPath]: apiSlice.reducer, 9 | }, 10 | middleware: (getDefaultMiddleware) => 11 | getDefaultMiddleware().concat(apiSlice.middleware), 12 | }); 13 | 14 | // Infer the `RootState` and `AppDispatch` types from the store itself 15 | export type RootState = ReturnType; 16 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 17 | export type AppDispatch = typeof store.dispatch; 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src", "tailwind.config.ts"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/utils/getDistanceBetweenPoints.ts: -------------------------------------------------------------------------------- 1 | import { Coordinate } from "ol/coordinate"; 2 | 3 | export const getDistanceBetweenTwoPoints = ( 4 | coord1: Coordinate, 5 | coord2: Coordinate 6 | ) => { 7 | console.log(coord1, coord2); 8 | const R = 6371; // Radius of the earth in km 9 | const dLat = ((coord1[1] - coord2[1]) * Math.PI) / 180; 10 | const dLon = ((coord1[0] - coord2[1]) * Math.PI) / 180; 11 | const a = 12 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 13 | Math.cos((coord2[1] * Math.PI) / 180) * 14 | Math.cos((coord1[1] * Math.PI) / 180) * 15 | Math.sin(dLon / 2) * 16 | Math.sin(dLon / 2); 17 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 18 | const distance = R * c; 19 | 20 | return Math.round(distance * 100) / 100; 21 | }; 22 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | import json 4 | import uuid 5 | 6 | class Config: 7 | @classmethod 8 | def load(cls): 9 | config = cls.load_from_file('config.json') 10 | random_uuid = str(uuid.uuid4()) 11 | config['broker']['client_id'] = config['broker']['client_id_prefix'] + '-' + random_uuid 12 | config['server']['start_time'] = datetime.datetime.now(datetime.timezone.utc).astimezone() 13 | 14 | try: 15 | version_info = cls.load_from_file('version-info.json') 16 | if version_info is not None: 17 | config['server']['version_info'] = version_info 18 | except FileNotFoundError: 19 | pass 20 | 21 | return config 22 | 23 | @classmethod 24 | def load_from_file(cls, path): 25 | with open(path, 'r') as f: 26 | return json.load(f) 27 | -------------------------------------------------------------------------------- /frontend/src/slices/appSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | export const appSlice = createSlice({ 4 | name: "app", 5 | initialState: { 6 | value: 7, 7 | }, 8 | reducers: { 9 | increment: (state) => { 10 | // Redux Toolkit allows us to write "mutating" logic in reducers. It 11 | // doesn't actually mutate the state because it uses the immer library, 12 | // which detects changes to a "draft state" and produces a brand new 13 | // immutable state based off those changes 14 | state.value += 1; 15 | }, 16 | decrement: (state) => { 17 | state.value -= 1; 18 | }, 19 | incrementByAmount: (state, action) => { 20 | state.value += action.payload; 21 | }, 22 | }, 23 | }); 24 | 25 | export const { increment, decrement, incrementByAmount } = appSlice.actions; 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kevinelliott # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /public/images/icons/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/images/icons/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | "airbnb", 9 | "airbnb-typescript", 10 | "prettier", 11 | ], 12 | ignorePatterns: ["dist", ".eslintrc.cjs"], 13 | parser: "@typescript-eslint/parser", 14 | parserOptions: { 15 | project: "./tsconfig.json", 16 | tsconfigRootDir: __dirname, 17 | }, 18 | plugins: [ 19 | "react-refresh", 20 | "react", 21 | "@typescript-eslint", 22 | "simple-import-sort", 23 | "sort-keys-fix", 24 | ], 25 | rules: { 26 | "react-refresh/only-export-components": [ 27 | "warn", 28 | { allowConstantExport: true }, 29 | ], 30 | "import/prefer-default-export": "off", 31 | "react/react-in-jsx-scope": "off", 32 | quotes: "off", 33 | "@typescript-eslint/quotes": "off", 34 | "react/function-component-definition": "off", 35 | "simple-import-sort/imports": "error", 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /public/images/icons/battery.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/images/icons/route2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/images/icons/route2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /models/node.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | class Node(): 4 | @staticmethod 5 | def default_node(id: str): 6 | id = id.replace('!', '') 7 | if id == 'ffffffff': 8 | return { 9 | 'id': id, 10 | 'neighborinfo': None, 11 | 'hardware': None, 12 | 'longname': 'Everyone', 13 | 'shortname': 'ALL', 14 | 'position': None, 15 | 'telemetry': None, 16 | 'active': False, 17 | 'since': datetime.datetime.now(datetime.timezone.utc).astimezone() - datetime.datetime.now(datetime.timezone.utc).astimezone(), 18 | 'last_seen': datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat() 19 | } 20 | 21 | return { 22 | 'id': id, 23 | 'neighborinfo': None, 24 | 'hardware': None, 25 | 'longname': 'Unknown', 26 | 'shortname': 'UNK', 27 | 'position': None, 28 | 'telemetry': None, 29 | 'active': True, 30 | 'since': datetime.datetime.now(datetime.timezone.utc).astimezone() - datetime.datetime.now(datetime.timezone.utc).astimezone(), 31 | 'last_seen': datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat() 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.11.1 as frontend 2 | 3 | COPY . . 4 | 5 | WORKDIR /frontend 6 | 7 | RUN corepack enable 8 | RUN yarn 9 | RUN yarn build --base=/next 10 | 11 | FROM python:3.12-slim 12 | 13 | LABEL org.opencontainers.image.source https://github.com/MeshAddicts/meshinfo 14 | LABEL org.opencontainers.image.description "Realtime web UI to run against a Meshtastic regional or private mesh network." 15 | 16 | ENV MQTT_TLS=false 17 | ENV PYTHONUNBUFFERED=1 18 | 19 | # Set the working directory in the container 20 | RUN mkdir /app 21 | WORKDIR /app 22 | 23 | # Copy the requirements file 24 | COPY requirements.txt . 25 | 26 | # Update pip 27 | RUN pip install --upgrade pip 28 | 29 | # Install Python dependencies 30 | RUN pip install --no-cache-dir -r requirements.txt 31 | 32 | # Add a HEALTHCHECK instruction 33 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD python -c "import socket; s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); result = s.connect_ex(('localhost', 8000)); s.close(); exit(result)" 34 | 35 | # Copy the project code 36 | COPY . . 37 | RUN chmod +x run.sh 38 | 39 | COPY --from=frontend /frontend/dist /app/dist 40 | 41 | # Set the command to run the application 42 | CMD ["./run.sh"] 43 | -------------------------------------------------------------------------------- /encoders.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import datetime 4 | import json 5 | 6 | class _JSONEncoder(json.JSONEncoder): 7 | def default(self, obj): 8 | if isinstance(obj, datetime.datetime): 9 | return obj.astimezone().isoformat() 10 | if isinstance(obj, datetime.timedelta): 11 | return None 12 | return obj 13 | 14 | class _JSONDecoder(json.JSONDecoder): 15 | def __init__(self, *args, **kwargs): 16 | json.JSONDecoder.__init__( 17 | self, object_hook=self.object_hook, *args, **kwargs) 18 | 19 | def object_hook(self, obj): 20 | ret = {} 21 | for key, value in obj.items(): 22 | if key in {'last_seen', 'last_geocoding'}: 23 | ret[key] = datetime.datetime.fromisoformat(value) 24 | elif key in {'id'}: 25 | if isinstance(value, str): 26 | ret[key] = value.replace('!', '') 27 | else: 28 | ret[key] = value 29 | elif key in {'sender'}: 30 | if isinstance(value, str): 31 | ret[key] = value.replace('!', '') 32 | else: 33 | ret[key] = value 34 | else: 35 | if isinstance(value, str): 36 | ret[key] = value.replace('!', '') 37 | else: 38 | ret[key] = value 39 | return ret 40 | -------------------------------------------------------------------------------- /public/images/icons/stats.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/images/icons/stats.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /templates/static/mesh_log.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Mesh Messages{% endblock %} 4 | 5 | {% block content %} 6 |
Mesh Messages
7 |

Mesh Messages

8 |

9 | All messages from the mesh as seen by 10 | {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). Only the messages received 11 | since this server was last restarted are shown. 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for message in messages[::-1] %} 20 | 21 | 26 | 27 | 28 | {% endfor %} 29 |
TimestampMessage
22 | {% if message.timestamp %} 23 | {{ datetime.fromtimestamp(message.timestamp).astimezone(zoneinfo) }} 24 | {% endif %} 25 | {{ json.dumps(message, indent=2, cls=JSONEncoder) }}
30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /mosquitto/config/mosquitto.conf: -------------------------------------------------------------------------------- 1 | # Enable logging 2 | log_type all 3 | log_dest stdout 4 | 5 | # Set the location of the log file 6 | log_dest file /mosquitto/log/mosquitto.log 7 | 8 | # Set the port on which Mosquitto will listen for incoming connections 9 | listener 1883 0.0.0.0 10 | 11 | # Set the maximum number of concurrent connections 12 | max_connections 100 13 | 14 | # Set the maximum packet size 15 | max_packet_size 65535 16 | 17 | # Enable persistence (saving of subscriptions and retained messages) 18 | persistence true 19 | persistence_location /mosquitto/data/ 20 | 21 | allow_anonymous true 22 | 23 | # Set the location of the password file 24 | #password_file /mosquitto/config/mosquitto.passwd 25 | 26 | # Set the location of the ACL file 27 | #acl_file /mosquitto/config/mosquitto.acl 28 | 29 | # Bridge to main MQTT broker 30 | connection bridge-meshtastic-public-receive 31 | address mqtt.meshtastic.org:1883 32 | topic US/+/+/2/json/# in 0 msh/ msh/ 33 | remote_username meshdev 34 | remote_password large4cats 35 | try_private false 36 | 37 | # connection bridge-meshtastic-public-send 38 | # address mqtt.meshtastic.org:1883 39 | # topic # out 0 msh/2/json/LongFast/!4355f528 msh/CA/US/SacValley/2/json/LongFast/!4355f528 40 | # remote_username meshdev 41 | # remote_password large4cats 42 | # try_private false 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy:latest 4 | ports: 5 | - 80:80 6 | - 443:443 7 | volumes: 8 | - ./caddy/data:/data/caddy 9 | - ./Caddyfile:/etc/caddy/Caddyfile 10 | - ./output/static-html:/srv 11 | - ./spa:/srv/next 12 | - ./public/images:/srv/images 13 | environment: 14 | - CADDY_AGREE=true 15 | restart: always 16 | 17 | postgres: 18 | image: postgres:latest 19 | volumes: 20 | - ./postgres/data:/var/lib/postgresql/data 21 | environment: 22 | - POSTGRES_PASSWORD=password 23 | restart: always 24 | 25 | mqtt: 26 | container_name: mqtt 27 | image: eclipse-mosquitto:latest 28 | ports: 29 | - 1883:1883 30 | volumes: 31 | - ./mosquitto/data:/mosquitto/data:rw 32 | - ./mosquitto/config:/mosquitto/config:rw 33 | restart: always 34 | 35 | meshinfo: 36 | image: ghcr.io/meshaddicts/meshinfo:latest 37 | volumes: 38 | - ./config.json:/app/config.json 39 | - ./output:/app/output 40 | environment: 41 | - PYTHONUNBUFFERED=1 42 | - MQTT_HOST=mqtt 43 | - MQTT_PORT=1883 44 | - MQTT_USERNAME=meshinfo 45 | - MQTT_PASSWORD=m3sht4st1c 46 | restart: always 47 | depends_on: 48 | - caddy 49 | - postgres 50 | - mqtt 51 | -------------------------------------------------------------------------------- /public/images/icons/temperature.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/images/icons/pressure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy:latest 4 | ports: 5 | - 8000:80 6 | - 8443:443 7 | volumes: 8 | - ./caddy/data:/data/caddy 9 | - ./Caddyfile.dev:/etc/caddy/Caddyfile 10 | - ./output/static-html:/srv 11 | - ./spa:/srv/next 12 | - ./public/images:/srv/images 13 | environment: 14 | - CADDY_AGREE=true 15 | restart: always 16 | 17 | postgres: 18 | image: postgres:latest 19 | volumes: 20 | - ./postgres/data:/var/lib/postgresql/data 21 | environment: 22 | - POSTGRES_PASSWORD=password 23 | restart: always 24 | 25 | mqtt: 26 | container_name: mqtt 27 | image: eclipse-mosquitto:latest 28 | ports: 29 | - 1883:1883 30 | volumes: 31 | - ./mosquitto/data:/mosquitto/data:rw 32 | - ./mosquitto/config:/mosquitto/config:rw 33 | restart: always 34 | 35 | meshinfo: 36 | build: 37 | context: . 38 | dockerfile: Dockerfile 39 | volumes: 40 | - ./config.json:/app/config.json 41 | - ./output:/app/output 42 | - ./templates:/app/templates 43 | - ./spa:/app/spa 44 | environment: 45 | - PYTHONUNBUFFERED=1 46 | - MQTT_HOST=mqtt 47 | - MQTT_PORT=1883 48 | - MQTT_USERNAME=meshinfo 49 | - MQTT_PASSWORD=m3sht4st1c 50 | restart: always 51 | depends_on: 52 | - caddy 53 | - postgres 54 | - mqtt 55 | -------------------------------------------------------------------------------- /templates/static/mqtt_log.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}MQTT Messages{% endblock %} 4 | 5 | {% block content %} 6 |
MQTT Messages
7 |

MQTT Messages

8 |

9 | All messages received by MQTT from the mesh as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 10 | If multiple nodes are feeding this MQTT server, the messages will be from all of them. 11 | Only the messages received since this server was last restarted are shown. 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% for message in messages[::-1] %} 21 | 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 |
TimestampTopicMessage
{{ datetime.fromtimestamp(message.timestamp).astimezone(zoneinfo) }}{{ message.topic }}{{ json.dumps(message, indent=2, cls=JSONEncoder) }}
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /templates/static/graph.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Graph{% endblock %} 4 | 5 | {% macro graph_node(node) %} 6 | {% if node.neighbors_heard|length > 0 %} 7 |
    8 | {% for n in node.neighbors_heard %} 9 |
  1. 10 | 19 | {{ graph_node(n) }} 20 |
  2. 21 | {% endfor %} 22 |
23 | {% endif %} 24 | {% endmacro %} 25 | 26 | {% block content %} 27 |
Graph
28 |

Graph

29 |

30 | The graph of nodes connected by neighbors (heard and heard by) as seen on the mesh as seen by 31 | and starting with {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 32 | In the near future, this will be represented as a visual network graph and be interactive. 33 |

34 | 35 |
36 |
{{ graph['shortname'] }}
37 |
{{ graph_node(graph) }}
38 |
39 | 40 |
41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /public/images/icons/resistance.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /data_renderer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import json 5 | 6 | from encoders import _JSONEncoder 7 | 8 | class DataRenderer: 9 | def __init__(self, config, data): 10 | self.config = config 11 | self.data = data 12 | 13 | async def render(self): 14 | await asyncio.to_thread(self._render) 15 | 16 | def _render(self): 17 | self.save_file("chat.json", self.data.chat) 18 | print(f"Saved {len(self.data.chat['channels']['0']['messages'])} chat messages to file ({self.config['paths']['data']}/chat.json)") 19 | 20 | nodes = {} 21 | for id, node in self.data.nodes.items(): 22 | if id.startswith('!'): 23 | id = id.replace('!', '') 24 | if len(id) != 8: # 8 hex chars required, if not, we abandon it 25 | continue 26 | nodes[id] = node 27 | 28 | self.save_file("nodes.json", nodes) 29 | print(f"Saved {len(nodes)} nodes to file ({self.config['paths']['data']}/nodes.json)") 30 | 31 | self.save_file("telemetry.json", self.data.telemetry) 32 | print(f"Saved {len(self.data.telemetry)} telemetry to file ({self.config['paths']['data']}/telemetry.json)") 33 | 34 | self.save_file("traceroutes.json", self.data.traceroutes) 35 | print(f"Saved {len(self.data.traceroutes)} traceroutes to file ({self.config['paths']['data']}/traceroutes.json)") 36 | 37 | def save_file(self, filename, data): 38 | print(f"Saving {filename}") 39 | with open(f"{self.config['paths']['data']}/{filename}", "w", encoding='utf-8') as f: 40 | json.dump(data, f, indent=2, sort_keys=True, cls=_JSONEncoder) 41 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meshinfo-frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@reduxjs/toolkit": "^2.2.5", 14 | "autoprefixer": "^10.4.19", 15 | "date-fns": "^3.6.0", 16 | "ol": "^9.2.4", 17 | "postcss": "^8.4.38", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-redux": "^9.1.2", 21 | "react-router-dom": "^6.23.1", 22 | "tailwindcss": "^3.4.4" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^18.2.66", 26 | "@types/react-dom": "^18.2.22", 27 | "@typescript-eslint/eslint-plugin": "^7.0.0", 28 | "@typescript-eslint/parser": "^7.0.0", 29 | "@vitejs/plugin-react": "^4.2.1", 30 | "eslint": "^8.57.0", 31 | "eslint-config-airbnb": "^19.0.4", 32 | "eslint-config-airbnb-typescript": "^18.0.0", 33 | "eslint-config-prettier": "^9.1.0", 34 | "eslint-plugin-import": "^2.25.3", 35 | "eslint-plugin-jsx-a11y": "^6.5.1", 36 | "eslint-plugin-react": "^7.28.0", 37 | "eslint-plugin-react-hooks": "^4.3.0", 38 | "eslint-plugin-react-refresh": "^0.4.6", 39 | "eslint-plugin-simple-import-sort": "^12.1.0", 40 | "eslint-plugin-sort-keys-fix": "^1.1.2", 41 | "prettier": "^3.3.1", 42 | "typescript": "^5.2.2", 43 | "vite": "^5.2.0" 44 | }, 45 | "packageManager": "yarn@4.2.2", 46 | "engines": { 47 | "node": ">=20.11.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | - 'develop' 8 | tags: 9 | - v* 10 | workflow_dispatch: 11 | 12 | jobs: 13 | docker: 14 | name: Build and Push Docker Image 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v4 22 | with: 23 | images: | 24 | ghcr.io/MeshAddicts/meshinfo 25 | tags: | 26 | type=ref,event=branch 27 | type=semver,pattern={{version}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=semver,pattern={{major}} 30 | type=sha 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | - name: Login to GitHub Container Registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.repository_owner }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | - name: Gather Version Info to JSON File 42 | run: | 43 | echo '${{ toJSON(github) }}' > version-info.json 44 | - name: Build and push 45 | uses: docker/build-push-action@v5 46 | with: 47 | context: . 48 | platforms: linux/amd64,linux/arm64 49 | push: true 50 | tags: ${{ steps.meta.outputs.tags }} 51 | cache-from: type=gha 52 | cache-to: type=gha,mode=max 53 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import requests 4 | from geo import distance_between_two_points 5 | 6 | def calculate_distance_between_nodes(node1, node2): 7 | if node1 is None or node2 is None: 8 | return None 9 | if node1["position"] is None or node2["position"] is None: 10 | return None 11 | if 'latitude_i' not in node1["position"] or 'longitude_i' not in node1["position"] or 'latitude_i' not in node2["position"] or 'longitude_i' not in node2["position"] or node1["position"]["latitude_i"] is None or node1["position"]["longitude_i"] is None or node2["position"]["latitude_i"] is None or node2["position"]["longitude_i"] is None: 12 | return None 13 | return round(distance_between_two_points( 14 | node1["position"]["latitude_i"] / 10000000, 15 | node1["position"]["longitude_i"] / 10000000, 16 | node2["position"]["latitude_i"] / 10000000, 17 | node2["position"]["longitude_i"] / 10000000 18 | ), 2) 19 | 20 | def convert_node_id_from_int_to_hex(id: int): 21 | id_hex = f'{id:08x}' 22 | return id_hex 23 | 24 | def convert_node_id_from_hex_to_int(id: str): 25 | if id.startswith('!'): 26 | id = id.replace('!', '') 27 | return int(id, 16) 28 | 29 | def geocode_position(api_key: str, latitude: float, longitude: float): 30 | if latitude is None or longitude is None: 31 | return None 32 | print(f"Geocoding {latitude}, {longitude}") 33 | url = f"https://geocode.maps.co/reverse?lat={latitude}&lon={longitude}&api_key={api_key}" 34 | response = requests.get(url) 35 | if response.status_code != 200: 36 | return None 37 | print(f"Geocoded {latitude}, {longitude} to {response.json()}") 38 | return response.json() 39 | -------------------------------------------------------------------------------- /public/images/icons/map.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/public/images/icons/map.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /banner: -------------------------------------------------------------------------------- 1 | 2 | 3 | ▄▄ ██ ▄▄▄▄ 4 | ██ ▀▀ ██▀▀▀ 5 | ████▄██▄ ▄████▄ ▄▄█████▄ ██▄████▄ ████ ██▄████▄ ███████ ▄████▄ 6 | ██ ██ ██ ██▄▄▄▄██ ██▄▄▄▄ ▀ ██▀ ██ ██ ██▀ ██ ██ ██▀ ▀██ 7 | ██ ██ ██ ██▀▀▀▀▀▀ ▀▀▀▀██▄ ██ ██ ██ ██ ██ ██ ██ ██ 8 | ██ ██ ██ ▀██▄▄▄▄█ █▄▄▄▄▄██ ██ ██ ▄▄▄██▄▄▄ ██ ██ ██ ▀██▄▄██▀ 9 | ▀▀ ▀▀ ▀▀ ▀▀▀▀▀ ▀▀▀▀▀▀ ▀▀ ▀▀ ▀▀▀▀▀▀▀▀ ▀▀ ▀▀ ▀▀ ▀▀▀▀ 10 | 11 | -------------------------------------------------------------------------------- /asyncio_helper.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import paho.mqtt.client as mqtt_client 3 | 4 | class AsyncioHelper: 5 | def __init__(self, loop, client): 6 | self.loop = loop 7 | self.client = client 8 | self.client.on_socket_open = self.on_socket_open 9 | self.client.on_socket_close = self.on_socket_close 10 | self.client.on_socket_register_write = self.on_socket_register_write 11 | self.client.on_socket_unregister_write = self.on_socket_unregister_write 12 | 13 | def on_socket_open(self, client, userdata, sock): 14 | print("Socket opened") 15 | 16 | def cb(): 17 | print("Socket is readable, calling loop_read") 18 | client.loop_read() 19 | 20 | self.loop.add_reader(sock, cb) 21 | self.misc = self.loop.create_task(self.misc_loop()) 22 | 23 | def on_socket_close(self, client, userdata, sock): 24 | print("Socket closed") 25 | self.loop.remove_reader(sock) 26 | self.misc.cancel() 27 | 28 | def on_socket_register_write(self, client, userdata, sock): 29 | # print("Watching socket for writability.") 30 | 31 | def cb(): 32 | print("Socket is writable, calling loop_write") 33 | client.loop_write() 34 | 35 | self.loop.add_writer(sock, cb) 36 | 37 | def on_socket_unregister_write(self, client, userdata, sock): 38 | # print("Stop watching socket for writability.") 39 | self.loop.remove_writer(sock) 40 | 41 | async def misc_loop(self): 42 | print("misc_loop started") 43 | while self.client.loop_misc() == mqtt_client.MQTT_ERR_SUCCESS: 44 | try: 45 | await asyncio.sleep(1) 46 | except asyncio.CancelledError: 47 | break 48 | print("misc_loop finished") 49 | -------------------------------------------------------------------------------- /public/images/icons/humidity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /templates/static/stats.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Stats{% endblock %} 4 | 5 | {% block content %} 6 |
Stats
7 |

Stats

8 |

9 | Some revelations based on messages that have 10 | been heard by the mesh by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 11 |

12 | 13 |

Current

14 |
15 |

Active Nodes

16 |
17 |
{{ stats.active_nodes }}
18 |
19 |
20 | 21 |
22 |

Persisted

23 |
24 |

Known Nodes

25 |
26 |
{{ stats.total_nodes }}
27 |
28 |
29 |
30 |

Chat Messages in Channel 0

31 |
32 |
{{ stats.total_chat }}
33 |
34 |
35 |
36 |

Telemetry

37 |
38 |
{{ stats.total_telemetry }}
39 |
40 |
41 |
42 |

Traceroutes

43 |
44 |
{{ stats.total_traceroutes }}
45 |
46 |
47 |
48 | 49 |
50 |

Since Last Restart

51 |
52 |

Messages (Session Total)

53 |
54 |
{{ stats.total_messages }}
55 |
56 |
57 |
58 |

Messages (Session MQTT)

59 |
60 |
{{ stats.total_mqtt_messages }}
61 |
62 |
63 |
64 | 65 | {% endblock %} 66 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import datetime 5 | import json 6 | from zoneinfo import ZoneInfo 7 | import os 8 | import discord 9 | from dotenv import load_dotenv 10 | 11 | from api import api 12 | from bot import discord as discord_bot 13 | from config import Config 14 | from memory_data_store import MemoryDataStore 15 | from mqtt import MQTT 16 | 17 | load_dotenv() 18 | 19 | config = Config.load() 20 | data = MemoryDataStore(config) 21 | data.update('mqtt_connect_time', datetime.datetime.now(ZoneInfo(config['server']['timezone']))) 22 | 23 | async def main(): 24 | global config 25 | global data 26 | 27 | # output app banner from file banner 28 | banner = open('banner', 'r').read() 29 | print(banner) 30 | 31 | version = json.loads(open('version.json', 'r').read()) 32 | print(f"Version: {version['version']} (git sha: {version['git_sha']})") 33 | print() 34 | 35 | if not os.path.exists(config['paths']['output']): 36 | os.makedirs(config['paths']['output']) 37 | if not os.path.exists(config['paths']['data']): 38 | os.makedirs(config['paths']['data']) 39 | 40 | os.environ['TZ'] = config['server']['timezone'] 41 | 42 | data.load() 43 | await data.save() 44 | await data.backup() 45 | 46 | async with asyncio.TaskGroup() as tg: 47 | loop = asyncio.get_event_loop() 48 | api_server = api.API(config, data) 49 | tg.create_task(api_server.serve(loop)) 50 | if config['broker']['enabled'] is True: 51 | mqtt = MQTT(config, data) 52 | tg.create_task(mqtt.connect()) 53 | if config['integrations']['discord']['enabled'] is True: 54 | bot = discord_bot.DiscordBot(command_prefix="!", intents=discord.Intents.all(), config=config, data=data) 55 | tg.create_task(bot.start_server()) 56 | 57 | if __name__ == "__main__": 58 | asyncio.run(main()) 59 | -------------------------------------------------------------------------------- /frontend/src/slices/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; 2 | 3 | import { IChatResponse, INodesResponse } from "../types"; 4 | 5 | export const apiSlice = createApi({ 6 | reducerPath: "api", 7 | tagTypes: ["Chat", "Node"], 8 | baseQuery: fetchBaseQuery({ baseUrl: "/api/v1" }), 9 | endpoints: (builder) => ({ 10 | getChats: builder.query({ 11 | query: () => "chat.json", 12 | transformResponse: (response: IChatResponse) => { 13 | const channels = Object.fromEntries( 14 | Object.entries(response.channels).map(([id, channel]) => [ 15 | id, 16 | { 17 | ...channel, 18 | totalMessages: channel.messages.length, 19 | messages: Object.values( 20 | channel.messages.reduce( 21 | (acc, message) => ({ 22 | ...acc, 23 | [message.id]: { 24 | ...message, 25 | sender: (acc[message.id]?.sender ?? []).concat( 26 | message.sender 27 | ), 28 | }, 29 | }), 30 | {} as Record< 31 | string, 32 | IChatResponse["channels"]["0"]["messages"][0] 33 | > 34 | ) 35 | ), 36 | }, 37 | ]) 38 | ); 39 | return { channels }; 40 | }, 41 | providesTags: [{ type: "Chat", id: "LIST" }], 42 | }), 43 | getNodes: builder.query({ 44 | query: () => "nodes", 45 | transformResponse: (response: INodesResponse) => 46 | Object.fromEntries( 47 | Object.entries(response.nodes).map(([id, node]) => [ 48 | id, 49 | { 50 | ...node, 51 | position: node.position 52 | ? { 53 | ...node.position, 54 | latitude: node.position.latitude_i / 1e7, 55 | longitude: node.position.longitude_i / 1e7, 56 | } 57 | : undefined, 58 | }, 59 | ]) 60 | ), 61 | providesTags: [{ type: "Node", id: "LIST" }], 62 | }), 63 | }), 64 | }); 65 | 66 | export const { useGetChatsQuery, useGetNodesQuery } = apiSlice; 67 | -------------------------------------------------------------------------------- /public/images/icons/relative-humidity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /bot/discord.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | from discord.ext import commands 5 | import discord 6 | from dotenv import load_dotenv 7 | 8 | from bot.cogs.main_commands import MainCommands 9 | from memory_data_store import MemoryDataStore 10 | 11 | 12 | class DiscordBot(commands.Bot): 13 | def __init__( 14 | self, 15 | *args, 16 | config: dict, 17 | data: MemoryDataStore, 18 | **kwargs, 19 | ): 20 | super().__init__(*args, **kwargs) 21 | self.config = config 22 | self.data = data 23 | self.synced = False 24 | 25 | async def on_ready(self): 26 | print('Discord: Ready!') 27 | await self.wait_until_ready() 28 | if not self.synced: 29 | print("Discord: Syncing commands") 30 | guild = discord.Object(id=self.config['integrations']['discord']['guild']) 31 | self.tree.copy_global_to(guild=guild) 32 | await self.tree.sync(guild = discord.Object(id=self.config['integrations']['discord']['guild'])) 33 | self.synced = True 34 | 35 | 36 | async def on_message(self, message): 37 | print(f'Discord: {message.channel.id}: {message.author}: {message.content}') 38 | if message.content.startswith('!test'): 39 | await message.channel.send('Test successful!') 40 | await self.process_commands(message) 41 | 42 | async def start_server(self): 43 | print("Starting Discord Bot") 44 | await self.add_cog(MainCommands(self, self.config, self.data)) 45 | await self.start(self.config['integrations']['discord']['token']) 46 | print("Discord Bot Done!") 47 | 48 | async def main(): 49 | load_dotenv() 50 | # if os.environ.get("DISCORD_TOKEN") is not None: 51 | # token = os.environ["DISCORD_TOKEN"] 52 | # channel_id = os.environ["DISCORD_CHANNEL_ID"] 53 | # bot = DiscordBot( 54 | # command_prefix="!", 55 | # intents=discord.Intents.all(), 56 | # initial_guilds=[1234910729480441947], 57 | # ) 58 | # print("Adding cog MainCommands") 59 | # await bot.add_cog(MainCommands(bot)) 60 | # print("Starting bot") 61 | # await bot.start(token) 62 | # print("Bot started") 63 | # await bot.get_channel(channel_id).send("Hello.") 64 | # else: 65 | # print("Not running bot because DISCORD_TOKEN not set") 66 | 67 | if __name__ == "__main__": 68 | asyncio.run(main(), debug=True) 69 | -------------------------------------------------------------------------------- /public/images/icons/route.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/public/images/icons/route.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/images/icons/telemetry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/public/images/icons/telemetry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "mesh": { 3 | "name": "Sac Valley Mesh", 4 | "shortname": "SVM", 5 | "description": "Serving Meshtastic to the Sacramento Valley and surrounding areas.", 6 | "url": "https://sacvalleymesh.com", 7 | "contact": "https://sacvalleymesh.com", 8 | "country": "US", 9 | "region": "California", 10 | "metro": "Sacramento", 11 | "latitude": 38.58, 12 | "longitude": -121.49, 13 | "altitude": 0, 14 | "timezone": "America/Los_Angeles", 15 | "announce": { 16 | "enabled": true, 17 | "interval": 60 18 | }, 19 | "tools": [ 20 | { "name": "Armooo's MeshView", "url": "https://meshview.armooo.net" }, 21 | { "name": "Liam's Meshtastic Map", "url": "https://meshtastic.liamcottle.net" }, 22 | { "name": "MeshMap", "url": "https://meshmap.net" }, 23 | { "name": "Bay Mesh Explorer", "url": "https://app.bayme.sh" }, 24 | { "name": "HWT Path Profiler", "url": "https://heywhatsthat.com/profiler.html" } 25 | ] 26 | }, 27 | "broker": { 28 | "enabled": true, 29 | "host": "mqtt.meshtastic.org", 30 | "port": 1883, 31 | "username": "meshdev", 32 | "password": "large4cats", 33 | "client_id_prefix": "meshinfo-dev", 34 | "topics": [ 35 | "msh/US/CA/SacValley/#", 36 | "msh/US/CA/sacvalley/#" 37 | ], 38 | "decoders": { 39 | "protobuf": { "enabled": true }, 40 | "json": { "enabled": true } 41 | }, 42 | "channels": { 43 | "encryption": [ 44 | { "key": "1PG7OiApB1nwvP+rz05pAQ==", "key_name": "Default" } 45 | ], 46 | "display": [ "0" ] 47 | } 48 | }, 49 | "paths": { 50 | "backups": "output/backups", 51 | "data": "output/data", 52 | "output": "output/static-html", 53 | "templates": "templates" 54 | }, 55 | "server": { 56 | "node_id": "4355f528", 57 | "base_url": "REPLACE_WITH_THE_URL_OF_THIS_SERVER_WITHOUT_TRAILING_SLASH", 58 | "node_activity_prune_threshold": 7200, 59 | "timezone": "America/Los_Angeles", 60 | "intervals": { 61 | "data_save": 300, 62 | "render": 5 63 | }, 64 | "backups": { 65 | "enabled": true, 66 | "interval": 86400 67 | }, 68 | "enrich": { 69 | "enabled": true, 70 | "interval": 900, 71 | "provider": "world.meshinfo.network" 72 | }, 73 | "graph": { 74 | "enabled": true, 75 | "max_depth": 10 76 | } 77 | }, 78 | "integrations": { 79 | "discord": { 80 | "enabled": false, 81 | "token": "REPLACE_WITH_TOKEN", 82 | "guild": "REPLACE_WITH_GUILD_ID" 83 | }, 84 | "geocoding": { 85 | "enabled": false, 86 | "provider": "geocode.maps.co", 87 | "geocode.maps.co": { 88 | "api_key": "REPLACE_WITH_API_KEY" 89 | } 90 | } 91 | }, 92 | "debug": false 93 | } 94 | -------------------------------------------------------------------------------- /postgres/sql/meshinfo.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE nodes ( 2 | id bigint NOT NULL PRIMARY KEY, 3 | first_heard_at timestamp with time zone DEFAULT now() NOT NULL, 4 | last_heard_at timestamp with time zone DEFAULT now() NOT NULL 5 | ); 6 | 7 | CREATE TABLE node_infos ( 8 | id bigint NOT NULL PRIMARY KEY, 9 | node_id bigint NOT NULL, 10 | long_name character varying(100), 11 | short_name character varying(10), 12 | mac_addr character varying(20), 13 | hw_model character varying(20), 14 | role character varying, 15 | created_at timestamp with time zone DEFAULT now() NOT NULL 16 | ); 17 | 18 | CREATE TABLE node_positions ( 19 | id bigint NOT NULL PRIMARY KEY, 20 | node_id bigint NOT NULL, 21 | latitude double precision NOT NULL, 22 | longitude double precision NOT NULL, 23 | altitude double precision, 24 | geom public.geometry(PointZ,4326), 25 | created_at timestamp with time zone DEFAULT now() NOT NULL 26 | ); 27 | 28 | CREATE VIEW current_nodes AS 29 | SELECT DISTINCT ON (node_id) node_id, long_name, short_name, mac_addr, hw_model, role, latitude, longitude, altitude, geom 30 | FROM ( 31 | SELECT 32 | n.id AS node_id, 33 | ni.long_name, 34 | ni.short_name, 35 | ni.mac_addr, 36 | ni.hw_model, 37 | ni.role, 38 | np.latitude, 39 | np.longitude, 40 | np.altitude, 41 | np.geom, 42 | np.created_at 43 | FROM nodes n 44 | JOIN node_infos ni ON n.id = ni.node_id 45 | JOIN node_positions np ON n.id = np.node_id 46 | ORDER BY n.id, np.created_at DESC 47 | ) AS current_nodes; 48 | 49 | CREATE TABLE text_messages ( 50 | id BIGINT PRIMARY KEY, 51 | channel_id TEXT REFERENCES channels(id), 52 | from_node_id BIGINT REFERENCES nodes(id), 53 | sender_node_id BIGINT REFERENCES nodes(id), 54 | to_node_id TEXT REFERENCES nodes(node_id), 55 | hops_away INT, 56 | rssi INT, 57 | snr FLOAT, 58 | text TEXT, 59 | timestamp BIGINT, 60 | created_at TIMESTAMP DEFAULT now() 61 | ); 62 | 63 | CREATE TABLE channels ( 64 | id BIGINT PRIMARY KEY, 65 | name TEXT, 66 | created_at TIMESTAMP DEFAULT now() 67 | ); 68 | 69 | CREATE TABLE messages ( 70 | id BIGINT PRIMARY KEY, 71 | mqtt_message_id BIGINT REFERENCES mqtt_messages(id), 72 | channel_id BIGINT REFERENCES channels(id), 73 | from_node_id BIGINT REFERENCES nodes(id), 74 | sender_node_id BIGINT REFERENCES nodes(id), 75 | to_node_id BIGINT REFERENCES nodes(id), 76 | type varying character(20), 77 | hops_away INT, 78 | rssi INT, 79 | snr FLOAT, 80 | text TEXT, 81 | timestamp BIGINT, 82 | created_at TIMESTAMP DEFAULT now() 83 | ); 84 | 85 | CREATE TABLE mqtt_messages ( 86 | id BIGINT PRIMARY KEY, 87 | topic TEXT, 88 | payload TEXT, 89 | qos INT, 90 | retain BOOLEAN, 91 | timestamp BIGINT, 92 | created_at TIMESTAMP DEFAULT now() 93 | ); 94 | -------------------------------------------------------------------------------- /templates/static/traceroutes.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Traceroutes{% endblock %} 4 | 5 | {% block content %} 6 |
Traceroutes
7 |

Traceroutes

8 |

9 | Traceroutes as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 10 |

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% for item in traceroutes %} 25 | 26 | 27 | 35 | 43 | 44 | 57 | 58 | 59 | {% endfor %} 60 | 61 |
TimestampFromToHopsRouteRoute Hops
{{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }} 28 | {% set fnode = nodes[item.from] %} 29 | {% if fnode %} 30 | {{ fnode.shortname }} 31 | {% else %} 32 | UNK 33 | {% endif %} 34 | 36 | {% set tnode = nodes[item.to] %} 37 | {% if tnode %} 38 | {{ tnode.shortname }} 39 | {% else %} 40 | UNK 41 | {% endif %} 42 | {{ item.hops_away }} 45 | {% for hop in item.route_ids %} 46 | {% set hnode = nodes[hop] %} 47 | {% if hnode %} 48 | {{ hnode.shortname }} 49 | {% else %} 50 | UNK 51 | {% endif %} 52 | {% if not loop.last %} 53 | > 54 | {% endif %} 55 | {% endfor %} 56 | {{ item.route | length }}
62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IChatResponse { 2 | channels: Record; 3 | } 4 | 5 | export interface IChannel { 6 | messages: IMessage[]; 7 | totalMessages: number; 8 | name: string; 9 | } 10 | 11 | export interface IMessage { 12 | /** 13 | * ID is not unique 14 | */ 15 | id: number; 16 | to: string; 17 | from: string; 18 | sender: string[]; 19 | hops_away: number; 20 | timestamp: number; 21 | message: string; 22 | text: string; 23 | } 24 | 25 | export type INodesResponse = Record; 26 | 27 | export interface INode { 28 | id: string; 29 | shortname: string; 30 | longname: string; 31 | location: string; 32 | status: string; 33 | last_seen: string; 34 | hardware: number | null; 35 | position?: INodePosition; 36 | telemetry?: { [key: string]: number } | null; 37 | neighborinfo?: { 38 | last_sent_by_id: string; 39 | neighbors_count: number; 40 | node_broadcast_interval_secs: number; 41 | node_id: number; 42 | neighbors?: INeighbor[]; 43 | }; 44 | } 45 | 46 | export interface INeighbor { 47 | node_id: number; 48 | snr: number; 49 | distance?: number; 50 | } 51 | export interface INodePosition { 52 | altitude?: number; 53 | latitude_i: number; 54 | latitude: number; 55 | longitude_i: number; 56 | longitude: number; 57 | precision_bits?: number; 58 | time?: number; 59 | PDOP?: number; 60 | ground_speed?: number; 61 | sats_in_view?: number; 62 | ground_track?: number; 63 | timestamp?: number; 64 | } 65 | 66 | export enum HardwareModel { 67 | UNSET = 0, 68 | TLORA_V2 = 1, 69 | TLORA_V1 = 2, 70 | TLORA_V2_1_1P6 = 3, 71 | TBEAM = 4, 72 | HELTEC_V2_0 = 5, 73 | TBEAM_V0P7 = 6, 74 | T_ECHO = 7, 75 | TLORA_V1_1P3 = 8, 76 | RAK4631 = 9, 77 | HELTEC_V2_1 = 10, 78 | HELTEC_V1 = 11, 79 | LILYGO_TBEAM_S3_CORE = 12, 80 | RAK11200 = 13, 81 | NANO_G1 = 14, 82 | TLORA_V2_1_1P8 = 15, 83 | TLORA_T3_S3 = 16, 84 | NANO_G1_EXPLORER = 17, 85 | NANO_G2_ULTRA = 18, 86 | LORA_TYPE = 19, 87 | WIPHONE = 20, 88 | WIO_WM1110 = 21, 89 | RAK2560 = 22, 90 | HELTEC_HRU_3601 = 23, 91 | STATION_G1 = 25, 92 | RAK11310 = 26, 93 | SENSELORA_RP2040 = 27, 94 | SENSELORA_S3 = 28, 95 | CANARYONE = 29, 96 | RP2040_LORA = 30, 97 | STATION_G2 = 31, 98 | LORA_RELAY_V1 = 32, 99 | NRF52840DK = 33, 100 | PPR = 34, 101 | GENIEBLOCKS = 35, 102 | NRF52_UNKNOWN = 36, 103 | PORTDUINO = 37, 104 | ANDROID_SIM = 38, 105 | DIY_V1 = 39, 106 | NRF52840_PCA10059 = 40, 107 | DR_DEV = 41, 108 | M5STACK = 42, 109 | HELTEC_V3 = 43, 110 | HELTEC_WSL_V3 = 44, 111 | BETAFPV_2400_TX = 45, 112 | BETAFPV_900_NANO_TX = 46, 113 | RPI_PICO = 47, 114 | HELTEC_WIRELESS_TRACKER = 48, 115 | HELTEC_WIRELESS_PAPER = 49, 116 | T_DECK = 50, 117 | T_WATCH_S3 = 51, 118 | PICOMPUTER_S3 = 52, 119 | HELTEC_HT62 = 53, 120 | EBYTE_ESP32_S3 = 54, 121 | ESP32_S3_PICO = 55, 122 | CHATTER_2 = 56, 123 | HELTEC_WIRELESS_PAPER_V1_0 = 57, 124 | HELTEC_WIRELESS_TRACKER_V1_0 = 58, 125 | UNPHONE = 59, 126 | TD_LORAC = 60, 127 | CDEBYTE_EORA_S3 = 61, 128 | TWC_MESH_V4 = 62, 129 | NRF52_PROMICRO_DIY = 63, 130 | RADIOMASTER_900_BANDIT_NANO = 64, 131 | HELTEC_CAPSULE_SENSOR_V3 = 65, 132 | PRIVATE_HW = 255, 133 | } 134 | -------------------------------------------------------------------------------- /meshtastic_support.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from enum import Enum 4 | 5 | """ 6 | HardwareModel definition of Meshtastic supported hardware models 7 | from https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.HardwareModel 8 | """ 9 | class HardwareModel(Enum): 10 | UNSET = 0 11 | TLORA_V2 = 1 12 | TLORA_V1 = 2 13 | TLORA_V2_1_1P6 = 3 14 | TBEAM = 4 15 | HELTEC_V2_0 = 5 16 | TBEAM_V0P7 = 6 17 | T_ECHO = 7 18 | TLORA_V1_1P3 = 8 19 | RAK4631 = 9 20 | HELTEC_V2_1 = 10 21 | HELTEC_V1 = 11 22 | LILYGO_TBEAM_S3_CORE = 12 23 | RAK11200 = 13 24 | NANO_G1 = 14 25 | TLORA_V2_1_1P8 = 15 26 | TLORA_T3_S3 = 16 27 | NANO_G1_EXPLORER = 17 28 | NANO_G2_ULTRA = 18 29 | LORA_TYPE = 19 30 | WIPHONE = 20 31 | WIO_WM1110 = 21 32 | RAK2560 = 22 33 | HELTEC_HRU_3601 = 23 34 | STATION_G1 = 25 35 | RAK11310 = 26 36 | SENSELORA_RP2040 = 27 37 | SENSELORA_S3 = 28 38 | CANARYONE = 29 39 | RP2040_LORA = 30 40 | STATION_G2 = 31 41 | LORA_RELAY_V1 = 32 42 | NRF52840DK = 33 43 | PPR = 34 44 | GENIEBLOCKS = 35 45 | NRF52_UNKNOWN = 36 46 | PORTDUINO = 37 47 | ANDROID_SIM = 38 48 | DIY_V1 = 39 49 | NRF52840_PCA10059 = 40 50 | DR_DEV = 41 51 | M5STACK = 42 52 | HELTEC_V3 = 43 53 | HELTEC_WSL_V3 = 44 54 | BETAFPV_2400_TX = 45 55 | BETAFPV_900_NANO_TX = 46 56 | RPI_PICO = 47 57 | HELTEC_WIRELESS_TRACKER = 48 58 | HELTEC_WIRELESS_PAPER = 49 59 | T_DECK = 50 60 | T_WATCH_S3 = 51 61 | PICOMPUTER_S3 = 52 62 | HELTEC_HT62 = 53 63 | EBYTE_ESP32_S3 = 54 64 | ESP32_S3_PICO = 55 65 | CHATTER_2 = 56 66 | HELTEC_WIRELESS_PAPER_V1_0 = 57 67 | HELTEC_WIRELESS_TRACKER_V1_0 = 58 68 | UNPHONE = 59 69 | TD_LORAC = 60 70 | CDEBYTE_EORA_S3 = 61 71 | TWC_MESH_V4 = 62 72 | NRF52_PROMICRO_DIY = 63 73 | RADIOMASTER_900_BANDIT_NANO = 64 74 | HELTEC_CAPSULE_SENSOR_V3 = 65 75 | PRIVATE_HW = 255 76 | 77 | HARDWARE_PHOTOS = { 78 | HardwareModel.HELTEC_HT62: "HELTEC_HT62.png", 79 | HardwareModel.HELTEC_V2_0: "HELTEC_V2_0.png", 80 | HardwareModel.HELTEC_V2_1: "HELTEC_V2_1.png", 81 | HardwareModel.HELTEC_V3: "HELTEC_V3.png", 82 | HardwareModel.HELTEC_WIRELESS_PAPER: "HELTEC_WIRELESS_PAPER.png", 83 | HardwareModel.HELTEC_WIRELESS_PAPER_V1_0: "HELTEC_WIRELESS_PAPER_V1_0.png", 84 | HardwareModel.HELTEC_WIRELESS_TRACKER: "HELTEC_WIRELESS_TRACKER.png", 85 | HardwareModel.HELTEC_WIRELESS_TRACKER_V1_0: "HELTEC_WIRELESS_TRACKER_V1_0.png", 86 | HardwareModel.HELTEC_WSL_V3: "HELTEC_WSL_V3.png", 87 | HardwareModel.LILYGO_TBEAM_S3_CORE: "LILYGO_TBEAM_S3_CORE.png", 88 | HardwareModel.NANO_G1_EXPLORER: "NANO_G1_EXPLORER.png", 89 | HardwareModel.NANO_G2_ULTRA: "NANO_G2_ULTRA.png", 90 | HardwareModel.NRF52_PROMICRO_DIY: "NRF52_PROMICRO_DIY.png", 91 | HardwareModel.RAK11310: "RAK11310.png", 92 | HardwareModel.RAK4631: "RAK4631.png", 93 | HardwareModel.RP2040_LORA: "RP2040_LORA.png", 94 | HardwareModel.RPI_PICO: "RPI_PICO.png", 95 | HardwareModel.TBEAM: "TBEAM.png", 96 | HardwareModel.TLORA_T3_S3: "TLORA_T3_S3.png", 97 | HardwareModel.TLORA_V2_1_1P6: "TLORA_V2_1_1P6.png", 98 | HardwareModel.T_DECK: "T_DECK.png", 99 | HardwareModel.T_ECHO: "T_ECHO.png", 100 | HardwareModel.T_WATCH_S3: "T_WATCH_S3.png", 101 | HardwareModel.PRIVATE_HW: "PRIVATE_HW.png", 102 | } 103 | -------------------------------------------------------------------------------- /public/images/icons/neighbors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/public/images/icons/neighbors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/api/layout.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 14 | {% block head %}{% endblock %} 15 | 16 | 17 |
18 | 19 | 80 | 81 |
82 |
83 |
84 | {% block content %}{% endblock %} 85 |
86 |
87 |
88 |
89 | 90 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /public/images/icons/current.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/pages/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | import { useGetChatsQuery, useGetNodesQuery } from "../slices/apiSlice"; 4 | import { INode } from "../types"; 5 | import { getDistanceBetweenTwoPoints } from "../utils/getDistanceBetweenPoints"; 6 | 7 | // eslint-disable-next-line react/require-default-props 8 | const Distance = ({ node1, node2 }: { node1?: INode; node2?: INode }) => { 9 | if (node1 && node2 && node1.position && node2.position) { 10 | return ( 11 | <> 12 | {getDistanceBetweenTwoPoints( 13 | [node1?.position?.longitude, node1?.position?.latitude], 14 | [node2?.position?.longitude, node2?.position?.latitude] 15 | )} 16 | 17 | ); 18 | } 19 | 20 | return ; 21 | }; 22 | 23 | export const Chat = () => { 24 | const { data: chat } = useGetChatsQuery(); 25 | const { data: nodes = {} } = useGetNodesQuery(); 26 | 27 | return ( 28 |
29 |

Chat

30 |

31 | There are {chat?.channels[0].totalMessages} messages on channel 0 32 | that have been heard by the mesh by KE-R (!4355f528). 33 |

34 |

Last updated: {new Date().toString()}

35 | 36 |

Channel 0

37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {chat?.channels[0].messages.map((message, i) => ( 49 | // eslint-disable-next-line react/no-array-index-key 50 | 51 | 57 | 67 | 77 | 86 | 87 | 93 | 94 | 95 | ))} 96 | 97 |
TimeFromViaToHopsDXMessage
52 | {format( 53 | new Date(message.timestamp * 1000), 54 | "yyyy-MM-dd HH:MM:SS xx" 55 | )} 56 | 58 | {/* {{ message.from + " / " + nodes[message.from].longname if message.from in nodes else (message.from + ' / Unknown') }} */} 59 | 64 | {nodes[message.from]?.shortname ?? "UNK"} 65 | 66 | 68 | {message.sender.map((sender, x) => ( 69 | 72 | {nodes[sender]?.shortname ?? "UNK"} 73 | {x < message.sender.length - 1 ? "/" : ""} 74 | 75 | ))} 76 | 78 | 83 | {nodes[message.to]?.shortname ?? "UNK"} 84 | 85 | {message.hops_away} 88 | 92 | {message.text}
98 |
99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /scripts/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Exit script if command fails or uninitialized variables used 4 | set -euo pipefail 5 | 6 | # ================================== 7 | # Verify repo is clean 8 | # ================================== 9 | 10 | # List uncommitted changes and 11 | # check if the output is not empty 12 | if [ -n "$(git status --porcelain)" ]; then 13 | # Print error message 14 | printf "\nError: repo has uncommitted changes\n\n" 15 | # Exit with error code 16 | exit 1 17 | fi 18 | 19 | # ================================== 20 | # Get latest version from git tags 21 | # ================================== 22 | 23 | # List git tags sorted lexicographically 24 | # so version numbers sorted correctly 25 | GIT_TAGS=$(git tag --sort=version:refname) 26 | 27 | # Get last line of output which returns the 28 | # last tag (most recent version) 29 | GIT_TAG_LATEST=$(echo "$GIT_TAGS" | tail -n 1) 30 | 31 | # If no tag found, default to v0.0.0 32 | if [ -z "$GIT_TAG_LATEST" ]; then 33 | GIT_TAG_LATEST="v0.0.0" 34 | fi 35 | 36 | # Strip prefix 'v' from the tag to easily increment 37 | GIT_TAG_LATEST=$(echo "$GIT_TAG_LATEST" | sed 's/^v//') 38 | 39 | # ================================== 40 | # Increment version number 41 | # ================================== 42 | 43 | # Get version type from first argument passed to script 44 | VERSION_TYPE="${1-}" 45 | VERSION_NEXT="" 46 | 47 | if [ "$VERSION_TYPE" = "patch" ]; then 48 | # Increment patch version 49 | VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$NF++; print $1"."$2"."$NF}')" 50 | elif [ "$VERSION_TYPE" = "minor" ]; then 51 | # Increment minor version 52 | VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$2++; $3=0; print $1"."$2"."$3}')" 53 | elif [ "$VERSION_TYPE" = "major" ]; then 54 | # Increment major version 55 | VERSION_NEXT="$(echo "$GIT_TAG_LATEST" | awk -F. '{$1++; $2=0; $3=0; print $1"."$2"."$3}')" 56 | else 57 | # Print error for unknown versioning type 58 | printf "\nError: invalid VERSION_TYPE arg passed, must be 'patch', 'minor' or 'major'\n\n" 59 | # Exit with error code 60 | exit 1 61 | fi 62 | 63 | # ================================== 64 | # Update version.json file 65 | # ================================== 66 | 67 | # Example version.json: 68 | # { 69 | # "version": "0.0.298", "major": 0, "minor": 0, "patch": 298, "build_date_iso_8601": "2024-08-03T00:00:00-07:00", "git_sha": "84e255ddf357386afc98f8217bb52c8515ff9072" 70 | #} 71 | 72 | # Update version number in version.json 73 | jq ".version = \"$VERSION_NEXT\"" version.json >version.json.tmp 74 | mv version.json.tmp version.json 75 | 76 | # Update build number in version.json 77 | MAJOR=$(echo "$VERSION_NEXT" | awk -F. '{print $1}') 78 | MINOR=$(echo "$VERSION_NEXT" | awk -F. '{print $2}') 79 | PATCH=$(echo "$VERSION_NEXT" | awk -F. '{print $3}') 80 | jq ".major = $MAJOR | .minor = $MINOR | .patch = $PATCH" version.json >version.json.tmp 81 | mv version.json.tmp version.json 82 | 83 | # Update build date in version.json 84 | jq ".build_date_iso_8601 = \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\"" version.json >version.json.tmp 85 | mv version.json.tmp version.json 86 | 87 | # Update git sha in version.json 88 | GIT_SHA=$(git rev-parse --short HEAD) 89 | jq ".git_sha = \"$GIT_SHA\"" version.json >version.json.tmp 90 | mv version.json.tmp version.json 91 | 92 | # Commit the changes 93 | git add version.json 94 | git commit -m "build: bump version.json - v$VERSION_NEXT" 95 | 96 | # ================================== 97 | # Create git tag for new version 98 | # ================================== 99 | 100 | # Create an annotated tag 101 | git tag -a "v$VERSION_NEXT" -m "Release: v$VERSION_NEXT" 102 | 103 | # Optional: push commits and tag to remote 'main' branch 104 | git push origin main --follow-tags 105 | -------------------------------------------------------------------------------- /bot/cogs/main_commands.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from zoneinfo import ZoneInfo 3 | from discord.ext import commands 4 | import discord 5 | 6 | import utils 7 | 8 | class LookupFlags(commands.FlagConverter): 9 | node: str = commands.flag(description='Node') 10 | 11 | class MainCommands(commands.Cog): 12 | def __init__(self, bot, config, data): 13 | self.bot = bot 14 | self.config = config 15 | self.data = data 16 | 17 | @commands.Cog.listener() 18 | async def on_ready(self): 19 | print('Discord: Logged in') 20 | 21 | @commands.hybrid_command(name="lookup", description="Look up a node by ID (int or hex) or short name") 22 | async def lookup_node(self, ctx, *, flags: LookupFlags): 23 | print(f"Discord: /lookup: Looking up {flags.node}") 24 | try: 25 | id_int = int(flags.node, 10) 26 | id_hex = utils.convert_node_id_from_int_to_hex(id_int) 27 | except ValueError: 28 | id_hex = flags.node 29 | 30 | if id_hex not in self.data.nodes: 31 | for node_id, node in self.data.nodes.items(): 32 | if str(node['shortname']).lower() == flags.node.lower(): 33 | id_hex = node_id 34 | break 35 | 36 | if id_hex not in self.data.nodes: 37 | print(f"Discord: /lookup: Node {id_hex} not found.") 38 | await ctx.send(f"Node {id_hex} not found.") 39 | return 40 | 41 | id_int = utils.convert_node_id_from_hex_to_int(id_hex) 42 | node = self.data.nodes[id_hex] 43 | print(f"Discord: /lookup: Found {node['id']}") 44 | 45 | embed = discord.Embed( 46 | title=f"Node {node['shortname']}: {node['longname']}", 47 | url=f"{self.config['server']['base_url'].strip('/')}/node_{node['id']}.html", 48 | color=discord.Color.blue()) 49 | embed.set_thumbnail(url=f"https://api.dicebear.com/9.x/bottts-neutral/svg?seed={node['id']}") 50 | embed.add_field(name="ID (hex)", value=id_hex, inline=True) 51 | embed.add_field(name="ID (int)", value=id_int, inline=True) 52 | embed.add_field(name="Shortname", value=node['shortname'], inline=False) 53 | embed.add_field(name="Hardware", value=node['hardware'], inline=False) 54 | embed.add_field(name="Last Seen", value=node['last_seen'], inline=False) 55 | embed.add_field(name="Status", value=("Online" if node['active'] else "Offline"), inline=False) 56 | await ctx.send(embed=embed) 57 | 58 | @commands.hybrid_command(name="mesh", description="Information about the mesh") 59 | async def mesh_info(self, ctx): 60 | print(f"Discord: /mesh: Mesh info requested by {ctx.author}") 61 | embed = discord.Embed( 62 | title=f"Information about {self.config['mesh']['name']}", 63 | url=self.config['server']['base_url'].strip('/'), 64 | color=discord.Color.blue()) 65 | embed.add_field(name="Name", value=self.config['mesh']['name'], inline=False) 66 | embed.add_field(name="Shortname", value=self.config['mesh']['shortname'], inline=False) 67 | embed.add_field(name="Description", value=self.config['mesh']['description'], inline=False) 68 | embed.add_field(name="Official Website", value=self.config['mesh']['url'], inline=False) 69 | location = f"{self.config['mesh']['metro']}, {self.config['mesh']['region']}, {self.config['mesh']['country']}" 70 | embed.add_field(name="Location", value=location, inline=False) 71 | embed.add_field(name="Timezone", value=self.config['server']['timezone'], inline=False) 72 | embed.add_field(name="Known Nodes", value=len(self.data.nodes), inline=True) 73 | embed.add_field(name="Online Nodes", value=len([n for n in self.data.nodes.values() if n['active']]), inline=True) 74 | uptime = datetime.datetime.now().astimezone(ZoneInfo(self.config['server']['timezone'])) - self.config['server']['start_time'] 75 | embed.add_field(name="Server Uptime", value=f"{uptime.days}d {uptime.seconds // 3600}h {uptime.seconds // 60}m {uptime.seconds % 60}s", inline=False) 76 | embed.add_field(name="Messages Since Server Startup", value=len(self.data.messages), inline=True) 77 | await ctx.send(embed=embed) 78 | 79 | @commands.hybrid_command(name="ping", description="Ping the bot") 80 | async def ping(self, ctx): 81 | print(f"Discord: /ping: Pinged by {ctx.author}") 82 | await ctx.send(f'Pong! {round(self.bot.latency * 1000)}ms') 83 | 84 | @commands.hybrid_command(name="uptime", description="Uptime of MeshInfo instance") 85 | async def uptime(self, ctx): 86 | print(f"Discord: /uptime: Uptime requested by {ctx.author}") 87 | now = datetime.datetime.now().astimezone(ZoneInfo(self.config['server']['timezone'])) 88 | print(now) 89 | print(self.config['server']['start_time']) 90 | uptime = now - self.config['server']['start_time'] 91 | print(uptime) 92 | print(f"{uptime.days}d {uptime.seconds // 3600}h {uptime.seconds // 60}m {uptime.seconds % 60}s") 93 | await ctx.send(f'MeshInfo uptime: {uptime.days}d {uptime.seconds // 3600}h {uptime.seconds // 60}m {uptime.seconds % 60}s') 94 | -------------------------------------------------------------------------------- /frontend/src/pages/Nodes.tsx: -------------------------------------------------------------------------------- 1 | import { formatDuration, intervalToDuration } from "date-fns"; 2 | import { useMemo } from "react"; 3 | 4 | import { useGetNodesQuery } from "../slices/apiSlice"; 5 | import { HardwareModel } from "../types"; 6 | import { getDistanceBetweenTwoPoints } from "../utils/getDistanceBetweenPoints"; 7 | 8 | export const Nodes = () => { 9 | const { data: nodes } = useGetNodesQuery(); 10 | const { data: activeNodes } = useGetNodesQuery(); 11 | 12 | const serverNode = useMemo(() => nodes && nodes["4355f528"], [nodes]); 13 | 14 | if (!nodes || !activeNodes) return
Loading...
; 15 | 16 | return ( 17 |
18 |

Nodes

19 |

20 | There are {Object.keys(activeNodes).length} active out of a total 21 | of {Object.entries(nodes).length} seen nodes that have been heard 22 | by the mesh by KE-R (!4355f528). 23 |

24 |

Last updated: {new Date().toString()}

25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 39 | 40 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | {Object.entries(activeNodes).map(([id, node]) => ( 55 | 56 | 65 | 74 | 77 | 78 | {node.position ? ( 79 | <> 80 | 81 | 82 | 83 | 94 | 95 | ) : ( 96 | <> 97 | 106 | {node.telemetry ? ( 107 | <> 108 | 113 | 118 | 123 | 124 | ) : ( 125 | <> 126 | 132 | 143 | 144 | ))} 145 | 146 |
IDNameHardwareLast PositionNeighborsTelemetrySeen
38 | ShortLong 41 | AltitudeLatitudeLongitudeDXCountBatteryVoltageChan UtilLastSince
57 | {id ? ( 58 | 59 | {id.replace("!", "")} 60 | 61 | ) : ( 62 | id 63 | )} 64 | 66 | {id ? ( 67 | 68 | {node.shortname} 69 | 70 | ) : ( 71 | node.shortname 72 | )} 73 | 75 | {node.longname} 76 | {node.hardware ? HardwareModel[node.hardware] : ""}{node.position.altitude || ""}{node.position.latitude}{node.position.longitude} 84 | {serverNode?.position && 85 | getDistanceBetweenTwoPoints( 86 | [node.position.longitude, node.position.latitude], 87 | [ 88 | serverNode?.position?.longitude, 89 | serverNode?.position?.latitude, 90 | ] 91 | )}{" "} 92 | km 93 | 98 | 99 | 100 | 101 | 102 | )} 103 | 104 | {node.neighborinfo ? node.neighborinfo.neighbors_count : ""} 105 | 109 | {node.telemetry.battery_level 110 | ? `${node.telemetry.battery_level}%` 111 | : ""} 112 | 114 | {node.telemetry.voltage 115 | ? `${node.telemetry.voltage.toFixed(2)}V` 116 | : ""} 117 | 119 | {node.telemetry.channel_utilization 120 | ? `${node.telemetry.channel_utilization.toFixed(1)}%` 121 | : ""} 122 | 127 | 128 | 129 | 130 | )} 131 | {node.last_seen} 133 | {formatDuration( 134 | intervalToDuration({ 135 | start: new Date(node.last_seen), 136 | end: new Date(), 137 | }), 138 | { 139 | format: ["seconds"], 140 | } 141 | )} 142 |
147 |
148 |
149 | Download JSON 150 |
151 | ); 152 | }; 153 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | kevin@airframes.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MeshInfo 2 | 3 | Realtime web UI to run against a Meshtastic regional or private mesh network. 4 | 5 | [![Docker Image](https://github.com/MeshAddicts/meshinfo/actions/workflows/docker.yml/badge.svg)](https://github.com/MeshAddicts/meshinfo/actions/workflows/docker.yml) ![GitHub Release](https://img.shields.io/github/v/release/meshaddicts/meshinfo) ![GitHub commit activity](https://img.shields.io/github/commit-activity/t/meshaddicts/meshinfo) 6 | 7 | ## Overview 8 | 9 | MeshInfo is written in Python and connects to an MQTT server that is receiving Meshtastic messages for the purpose of visualizing and inspecting traffic. It (currently) uses a filesystem to persist content, such as node info and telemetry. There are plans to optionally support Postgres and SQLite3 as optional persistance storage methods. 10 | 11 | To make deployment to run an instance for your mesh easy, Docker support is included. We recommend using Docker Compose with a personalized version of the `docker-compose.yml` file to most easily deploy it, but any seasoned Docker user can also use the Docker image alone. 12 | 13 | If you use MeshInfo and have a publicly accessible instance, we'd like to know! Drop a note to kevin@airframes.io with details and we'll link it below. 14 | 15 | See an example instance running on the [Sacramento Valley Mesh](https://svm1.meshinfo.network/nodes.html). 16 | 17 | If you are running a high elevation node, preferrably a `Router` or `Repeater` node, you might be interested in getting on the notification list for a [cavity filter](https://shop.airframes.io/products/lora-915mhz-filter) that Kevin and Trevor are having made. 18 | 19 | If you're interested in aeronautical (ADS-B/ACARS/VDL/HFDL/SATCOM) or ship tracking (AIS), please take a look at sister project [Airframes](https://airframes.io) / [Airframes Github](https://github.com/airframesio). 20 | 21 | ## Screenshots 22 | 23 | [MeshInfo Screenshot 1](meshinfo1.png) 24 | [MeshInfo Screenshot 2](meshinfo2.png) 25 | [MeshInfo Screenshot 3](meshinfo3.png) 26 | [MeshInfo Screenshot 4](meshinfo4.png) 27 | [MeshInfo Screenshot 5](meshinfo5.png) 28 | 29 | ## Supported Meshtastic Message Types 30 | 31 | - neighborinfo 32 | - nodeinfo 33 | - position 34 | - telemetry 35 | - text 36 | - traceroute 37 | 38 | ## Features 39 | 40 | ### Current 41 | 42 | - Chat 43 | - Map 44 | - Nodes 45 | - Node Neighbors 46 | - Mesh Messages 47 | - MQTT Messages 48 | - Telemetry 49 | - Traceroutes 50 | 51 | ### Upcoming 52 | 53 | - Statistics 54 | - Overview of Routes 55 | 56 | ## Chat 57 | 58 | If you're using this and have questions, or perhaps you want to join in on the dev effort and want to interact collaboratively, come chat with us on [#meshinfo on the SacValleyMesh Discord](https://discord.gg/tj6dADagDJ). 59 | 60 | ## Running 61 | 62 | ### Docker Compose (preferred for 24/7 servers) 63 | 64 | #### Setup 65 | 66 | ##### Clone the repo 67 | 68 | ```sh 69 | git clone https://github.com/MeshAddicts/meshinfo.git 70 | cd meshinfo 71 | ``` 72 | 73 | ##### Edit Configuration 74 | 75 | 1. Copy and then edit the `config.json.sample` to `config.json`. 76 | 2. Edit the `Caddyfile` and be sure it is setup for your hostname (FQDN if requiring Let's Encrypt cert to be generated) and your email address for the TLS line. If you only wish to use a self-signed certificate (and are OK with the browser warnings about this), then change it from your email address to `tls internal`. 77 | 3. Edit the `docker-compose.yml` (or `docker-compose-dev.yml` if you are going to use that one) and adjust any port mappings for caddy if you wish to have it run on anything other than 80/443. Keep in mind that if you are not using a FQDN and ports 80/443, Caddy will fail to provision a Let's Encrypt certificate. This is because Let's Encrypt requires 80/443 to be accessible and this is not a limitation of Caddy nor MeshInfo. 78 | 79 | #### To Run 80 | 81 | Change to the directory. 82 | 83 | ```sh 84 | cd meshinfo 85 | ``` 86 | 87 | ```sh 88 | docker compose pull && docker compose down && docker compose up -d && docker compose ps && docker compose logs -f meshinfo 89 | ``` 90 | 91 | #### To Update 92 | 93 | ```sh 94 | git fetch && git pull && docker compose pull && docker compose down && docker compose up -d && docker compose ps && docker compose logs -f meshinfo 95 | ``` 96 | 97 | ### Directly (without Docker) 98 | 99 | Be sure you have `Python 3.12.4` or higher installed. 100 | 101 | ```sh 102 | pip install -r requirements.txt 103 | python main.py 104 | ``` 105 | 106 | ## Development 107 | 108 | ### Building a local Docker image 109 | 110 | Clone the repository. 111 | 112 | ```sh 113 | git clone https://github.com/MeshAddicts/meshinfo.git 114 | ``` 115 | 116 | If already existing, be sure to pull updates. 117 | 118 | ```sh 119 | git fetch && git pull 120 | ``` 121 | 122 | Build. Be sure to specify a related version number and suffix (this example `dev5` but could be your name or initials and a number) as this will help prevent collisions in your local image cache when testing. 123 | 124 | ```sh 125 | scripts/docker-build.sh 0.0.1dev5 126 | ``` 127 | 128 | ### Running via Docker Compose while developing 129 | 130 | ```sh 131 | docker compose -f docker-compose-dev.yml up --build --force-recreate 132 | ``` 133 | 134 | You will need to CTRL-C and run again if you make any changes to the python code, but not if you only make changes to 135 | the templates. 136 | 137 | ### Release 138 | 139 | Tag the release using git and push up the tag. The image will be build by GitHub automatically (see: https://github.com/MeshAddicts/meshinfo/actions/workflows/docker.yml). 140 | 141 | ```sh 142 | git tag v0.0.0 && git push && git push --tags 143 | ``` 144 | 145 | ## Contributing 146 | 147 | We happily accept Pull Requests! 148 | 149 | TODO: Need to rewrite this section. 150 | -------------------------------------------------------------------------------- /templates/static/layout-map.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 14 | {% block head %}{% endblock %} 15 | 16 | 17 |
18 | 19 | 137 | 138 |
139 | {% block content %}{% endblock %} 140 |
141 |
142 | 143 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /templates/static/chat.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Chat{% endblock %} 4 | 5 | {% block content %} 6 |
Chat
7 |

Chat

8 | {% for channel in chat['channels'] if channel in config['broker']['channels']['display'] %} 9 |

10 | There are {{ chat['channels'][channel]['messages']|count }} messages on channel {{ channel }} that have 11 | been heard by the mesh by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 12 |

13 | {% endfor %} 14 | 15 |
16 |
17 | 18 | 19 | 24 |
25 | 42 |
43 | 44 | {% for channel in chat['channels'] if channel in config['broker']['channels']['display'] %} 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {% for message in chat['channels'][channel]['messages'] %} 60 | {% set node_from = nodes[message.from] if message.from in nodes else None %} 61 | {% set node_sender = nodes[message.sender] if message.sender in nodes else None %} 62 | {% set node_to = nodes[message.to] if message.to in nodes else None %} 63 | {% set distance_from_sender = utils.calculate_distance_between_nodes(node_from, node_sender) if node_from and node_sender else None %} 64 | 65 | 66 | 71 | 78 | 87 | 88 | 93 | 96 | 97 | {% endfor %} 98 | 99 |
TimeFromViaToHopsDXMessage
{{ datetime.fromtimestamp(message.timestamp).astimezone(zoneinfo).strftime('%Y-%m-%d %H:%M:%S %z') }} 67 | 68 | {{ nodes[message.from].shortname if message.from in nodes else 'UNK' }} 69 | 70 | 72 | {% if node_sender %} 73 | 74 | {{ nodes[message.sender].shortname if message.sender in nodes else 'UNK' }} 75 | 76 | {% endif %} 77 | 79 | {% if node_to and node_to.id != 'ffffffff' %} 80 | 81 | {{ nodes[message.to].shortname if message.to in nodes else 'UNK' }} 82 | 83 | {% else %} 84 | ALL 85 | {% endif %} 86 | {{ message.hops_away }} 89 | {% if distance_from_sender %} 90 | {{ distance_from_sender }} km 91 | {% endif %} 92 | 94 | {{ message.text }} 95 |
100 |
101 | {% endfor %} 102 | {% endblock %} 103 | -------------------------------------------------------------------------------- /templates/static/neighbors.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Neighbors{% endblock %} 4 | 5 | {% block content %} 6 |
Neighbors
7 |

Neighbors

8 |

9 | There are {{ active_nodes_with_neighbors|count }} active nodes with neighbors 10 | that have been heard by the mesh by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% for id, node in active_nodes_with_neighbors.items() %} 34 | 35 | 47 | 54 | 57 | {% if node.neighborinfo %} 58 | 83 | 115 | {% else %} 116 | 117 | {% endif %} 118 | 119 | 120 | 121 | {% endfor %} 122 | 123 |
IDNameNeighbors
ShortLongHeardHeard ByInterval
36 | {% if id %} 37 | {% set id = id|replace('!', '') %} 38 | 39 | Avatar 40 | {{ id }} 41 | 42 | {% else %} 43 | Avatar 44 | {{ id }} 45 | {% endif %} 46 | 48 | {% if id %} 49 | {{ node.shortname }} 50 | {% else %} 51 | {{ node.shortname }} 52 | {% endif %} 53 | 55 | {{ node.longname }} 56 | 59 | 60 | 61 | {% for neighbor in node.neighborinfo.neighbors %} 62 | 63 | 70 | 73 | 78 | 79 | {% endfor %} 80 | 81 |
64 | {% if neighbor.node_id in nodes %} 65 | {{ nodes[neighbor.node_id].shortname }} 66 | {% else %} 67 | UNK 68 | {% endif %} 69 | 71 | SNR: {{ neighbor.snr }} 72 | 74 | {% if neighbor.distance %} 75 | {{ neighbor.distance }} km 76 | {% endif %} 77 |
82 |
84 | 85 | 86 | {% for nid, nnode in nodes.items() %} 87 | {% if nnode.neighborinfo %} 88 | {% for neighbor in nnode.neighborinfo.neighbors %} 89 | {% if utils.convert_node_id_from_int_to_hex(neighbor.node_id) == id %} 90 | 91 | 98 | 101 | 107 | 108 | {% endif %} 109 | {% endfor %} 110 | {% endif %} 111 | {% endfor %} 112 | 113 |
92 | {% if nid in nodes %} 93 | {{ nodes[nid].shortname }} 94 | {% else %} 95 | UNK 96 | {% endif %} 97 | 99 | SNR: {{ neighbor.snr }} 100 | 102 | {% set dist = utils.calculate_distance_between_nodes(nodes[nid], nodes[id]) %} 103 | {% if dist %} 104 | {{ dist }} km 105 | {% endif %} 106 |
114 |
{{ node.neighborinfo.node_broadcast_interval_secs }}s
124 |

125 |

126 | Download JSON 127 | {% endblock %} 128 | -------------------------------------------------------------------------------- /templates/static/layout.html.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 14 | {% block head %}{% endblock %} 15 | 16 | 17 |
18 | 19 | 145 | 146 |
147 |
148 |
149 | {% block content %}{% endblock %} 150 |
151 |
152 |
153 |
154 | 155 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MeshInfo 8 | 9 | 10 |
11 | 12 | 179 | 180 |
181 |
182 | 183 | 203 | 204 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /api/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from fastapi.encoders import jsonable_encoder 3 | import uvicorn 4 | from fastapi import FastAPI, Request 5 | from fastapi.responses import HTMLResponse, JSONResponse 6 | from fastapi.templating import Jinja2Templates 7 | 8 | import utils 9 | 10 | templates = Jinja2Templates(directory="./templates/api") 11 | app = FastAPI() 12 | 13 | class API: 14 | def __init__(self, config, data): 15 | self.config = config 16 | self.data = data 17 | 18 | async def serve(self, loop): 19 | @app.get("/", response_class=HTMLResponse) 20 | async def root(request: Request): 21 | return templates.TemplateResponse(request=request, name="index.html.j2", context={}) 22 | 23 | @app.get("/v1/nodes") 24 | async def nodes(request: Request) -> JSONResponse: 25 | nodes = self.data.nodes 26 | if "ids" in request.query_params.keys(): 27 | ids: str|None = request.query_params.get("ids") 28 | if ids is not None: 29 | ids = ids.strip() 30 | if ids != "": 31 | nodes_to_keep = [] 32 | for id in ids.split(","): 33 | try: 34 | node_id = int(id) 35 | node_id = utils.convert_node_id_from_int_to_hex(node_id) 36 | except ValueError: 37 | node_id = id 38 | if id in self.data.nodes: 39 | nodes_to_keep.append(node_id) 40 | nodes = { k: v for k, v in nodes.items() if k in nodes_to_keep } 41 | 42 | if "long_name" in request.query_params.keys(): 43 | longname: str|None = request.query_params.get("long_name") 44 | if longname is not None: 45 | longname = longname.strip() 46 | if longname != "": 47 | nodes_to_keep = [] 48 | for id in nodes: 49 | if longname.lower() in nodes[id]["longname"].lower(): 50 | nodes_to_keep.append(id) 51 | nodes = { k: v for k, v in nodes.items() if k in nodes_to_keep } 52 | 53 | if "short_name" in request.query_params.keys(): 54 | shortname: str|None = request.query_params.get("short_name") 55 | if shortname is not None: 56 | shortname = shortname.strip() 57 | if shortname != "": 58 | nodes_to_keep = [] 59 | for id in nodes: 60 | if shortname.lower() in nodes[id]["shortname"].lower(): 61 | nodes_to_keep.append(id) 62 | nodes = { k: v for k, v in nodes.items() if k in nodes_to_keep } 63 | 64 | if "status" in request.query_params.keys(): 65 | status: str|None = request.query_params.get("status") 66 | if status is not None: 67 | status = status.strip() 68 | if status == "online": 69 | nodes_to_keep = [] 70 | for id in nodes: 71 | if nodes[id]["active"] == True: 72 | nodes_to_keep.append(id) 73 | nodes = { k: v for k, v in nodes.items() if k in nodes_to_keep } 74 | elif status == "offline": 75 | nodes_to_keep = [] 76 | for id in nodes: 77 | if nodes[id]["active"] == False: 78 | nodes_to_keep.append(id) 79 | nodes = { k: v for k, v in nodes.items() if k in nodes_to_keep } 80 | 81 | return jsonable_encoder({ "nodes": nodes, "count": len(nodes) }) 82 | 83 | @app.get("/v1/nodes/{id}") 84 | async def node(request: Request, id: str) -> JSONResponse: 85 | try: 86 | node_id = int(id) 87 | node_id = utils.convert_node_id_from_int_to_hex(node_id) 88 | except ValueError: 89 | node_id = id 90 | 91 | if node_id in self.data.nodes: 92 | return jsonable_encoder({ "node": self.data.nodes[node_id] }) 93 | else: 94 | return JSONResponse(status_code=404, content={"error": "node not found"}) 95 | 96 | @app.get("/v1/nodes/{id}/telemetry") 97 | async def node_telemetry(request: Request, id: str) -> JSONResponse: 98 | try: 99 | node_id = int(id) 100 | node_id = utils.convert_node_id_from_int_to_hex(node_id) 101 | except ValueError: 102 | node_id = id 103 | 104 | if node_id in self.data.telemetry_by_node: 105 | return jsonable_encoder({ "telemetry": self.data.telemetry_by_node[node_id] }) 106 | else: 107 | return JSONResponse(status_code=404, content={"error": "telemetry not found"}) 108 | 109 | @app.get("/v1/nodes/{id}/texts") 110 | async def node_text(request: Request, id: str) -> JSONResponse: 111 | try: 112 | node_id = int(id) 113 | node_id = utils.convert_node_id_from_int_to_hex(node_id) 114 | except ValueError: 115 | node_id = id 116 | 117 | texts = [] 118 | for channel in self.data.chat['channels'].keys(): 119 | for message in self.data.chat['channels'][channel]['messages']: 120 | if message['from'] == node_id or message['to'] == node_id: 121 | texts.append(message) 122 | return jsonable_encoder({ "texts": texts }) 123 | 124 | @app.get("/v1/nodes/{id}/traceroutes") 125 | async def node_traceroutes(request: Request, id: str) -> JSONResponse: 126 | try: 127 | node_id = int(id) 128 | node_id = utils.convert_node_id_from_int_to_hex(node_id) 129 | except ValueError: 130 | node_id = id 131 | 132 | traceroutes = [] 133 | for traceroute in self.data.traceroutes: 134 | if traceroute['from'] == node_id or traceroute['to'] == node_id: 135 | traceroutes.append(traceroute) 136 | return jsonable_encoder({ "traceroutes": traceroutes }) 137 | 138 | @app.get("/v1/server/config") 139 | async def server_config(request: Request) -> JSONResponse: 140 | # TODO: Sanitize config (i.e. username, password, api_key, etc) 141 | return jsonable_encoder({ "config": self.config }) 142 | 143 | 144 | 145 | conf = uvicorn.Config(app=app, host="0.0.0.0", port=9000, loop=loop) 146 | server = uvicorn.Server(conf) 147 | print(f"Starting Uvicorn server bound at http://{conf.host}:{conf.port}") 148 | await server.serve() 149 | print("Uvicorn server stopped") 150 | -------------------------------------------------------------------------------- /templates/static/telemetry.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Telemetry{% endblock %} 4 | 5 | {% block content %} 6 |
Telemetry
7 |

Telemetry

8 |

9 | Telemetry as seen by {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 10 |

11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 23 | 26 | 27 | 30 | 33 | 36 | 39 | 42 | 45 | 46 | 47 | 48 | {% for item in telemetry[0:1000] %} 49 | {% set inode = nodes[item.from] %} 50 | 51 | 58 | 65 | 70 | 75 | 80 | 85 | 122 | 157 | 162 | 171 | 176 | 181 | 182 | {% endfor %} 183 | 184 |
TimestampNode 18 | Air Util TX 19 | 21 | Channel Util 22 | 24 | Battery 25 | Uptime 28 | Voltage 29 |
52 | {% if 'timestamp' in item %} 53 | {{ datetime.fromtimestamp(item.timestamp).astimezone(zoneinfo) }} 54 | {% else %} 55 | Unknown 56 | {% endif %} 57 | 59 | {% if inode %} 60 | {{ inode.shortname }} 61 | {% else %} 62 | UNK 63 | {% endif %} 64 | 66 | {% if item.payload.air_util_tx is defined %} 67 | {{ item.payload.air_util_tx | round(2) }}% 68 | {% endif %} 69 | 71 | {% if item.payload.channel_utilization is defined %} 72 | {{ item.payload.channel_utilization | round(1) }}% 73 | {% endif %} 74 | 76 | {% if item.payload.battery_level is defined %} 77 | {{ item.payload.battery_level | round(2) }}% 78 | {% endif %} 79 | 81 | {% if item.payload.uptime_seconds is defined %} 82 | {{ item.payload.uptime_seconds }} 83 | {% endif %} 84 | 86 | {% if item.payload.voltage is defined %} 87 | {% if item.payload.voltage is string %} 88 | {{ item.payload.voltage }} 89 | {% else %} 90 | {{ item.payload.voltage | round(2) }} V 91 | {% endif %} 92 | {% endif %} 93 | {% if item.payload.voltage_ch1 is defined and item.payload.voltage_ch2 is defined and item.payload.voltage_ch3 is defined %} 94 | 95 | 96 | 99 | 102 | 103 | 104 | 107 | 110 | 111 | 112 | 115 | 118 | 119 |
97 | Ch1 98 | 100 | {{ item.payload.voltage_ch1 | round(2) }} V
101 |
105 | Ch2 106 | 108 | {{ item.payload.voltage_ch2 | round(2) }} V
109 |
113 | Ch3 114 | 116 | {{ item.payload.voltage_ch3 | round(2) }} V 117 |
120 | {% endif %} 121 |
185 | {% endblock %} 186 | -------------------------------------------------------------------------------- /templates/static/nodes.html.j2: -------------------------------------------------------------------------------- 1 | {% extends "templates/static/layout.html.j2" %} 2 | 3 | {% block title %}Nodes{% endblock %} 4 | 5 | {% block content %} 6 |
Nodes
7 |

Nodes

8 |

9 | There are {{ active_nodes|count }} active out of a total of {{ nodes|count }} seen nodes 10 | that have been heard by the mesh by 11 | {{ config['server']['node_id'] }} ({{ config['server']['node_id'] }}). 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 43 | 46 | 49 | 50 | 51 | 52 | 53 | {% for id, node in active_nodes.items() %} 54 | 55 | 58 | 66 | 74 | 77 | 84 | 111 | {% if node.position %} 112 | 117 | 118 | 119 | 120 | {% else %} 121 | 122 | 123 | 124 | 125 | {% endif %} 126 | {% if node.neighborinfo %} 127 | 128 | {% else %} 129 | 130 | {% endif %} 131 | {% if node.telemetry %} 132 | 137 | 142 | 147 | 152 | {% else %} 153 | 154 | 155 | 156 | 157 | {% endif %} 158 | 159 | 160 | {% endfor %} 161 | 162 |
IDNameRoleSeen
ShortLongSince
56 | Avatar 57 | 59 | {% if id %} 60 | {% set id = id|replace('!', '') %} 61 | {{ id }} 62 | {% else %} 63 | {{ id }} 64 | {% endif %} 65 | 67 | {% if id %} 68 | {% set id = id|replace('!', '') %} 69 | {{ node.shortname }} 70 | {% else %} 71 | {{ node.shortname }} 72 | {% endif %} 73 | 75 | {{ node.longname }} 76 | 85 | {% if node.role is not none %} 86 | {% if node.role == 0 %} 87 | C 88 | {% elif node.role == 1 %} 89 | CM 90 | {% elif node.role == 2 %} 91 | R 92 | {% elif node.role == 3 %} 93 | RC 94 | {% elif node.role == 4 %} 95 | RE 96 | {% elif node.role == 5 %} 97 | T 98 | {% elif node.role == 6 %} 99 | S 100 | {% elif node.role == 7 %} 101 | A 102 | {% elif node.role == 8 %} 103 | CH 104 | {% elif node.role == 9 %} 105 | LF 106 | {% elif node.role == 10 %} 107 | AT 108 | {% endif %} 109 | {% endif %} 110 | {{ node.since.seconds }} secs
163 |

164 |

165 | Download JSON 166 | {% endblock %} 167 | --------------------------------------------------------------------------------