diff --git a/.env.example b/.env.example index 7d9d30b..5091667 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ SITE_OG_IMAGE= SITE_URL=https://your-domain.example/ SITE_ICON=/static/logo.png SITE_FEED_NOTE=Feed: MQTT broker. +CUSTOM_LINK_URL= DISTANCE_UNITS=km NODE_MARKER_RADIUS=8 HISTORY_LINK_SCALE=1 @@ -46,8 +47,14 @@ ROUTE_HISTORY_FILE=/data/route_history.jsonl ROUTE_HISTORY_PAYLOAD_TYPES=8,9,2,5,4 MQTT_ONLINE_SECONDS=300 MQTT_ONLINE_TOPIC_SUFFIXES=/status,/packets +MQTT_ONLINE_FORCE_NAMES= MQTT_SEEN_BROADCAST_MIN_SECONDS=5 +GIT_CHECK_ENABLED=false +GIT_CHECK_FETCH=false +GIT_CHECK_PATH=/repo +GIT_CHECK_INTERVAL_SECONDS=43200 + MAP_START_LAT=42.3601 MAP_START_LON=-71.1500 MAP_START_ZOOM=10 diff --git a/AGENTS.md b/AGENTS.md index b79f6f2..3d921ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ - `docker-compose.yaml` runs the service as `meshmap-live`. - `data/` stores persisted state (`state.json`), route history (`route_history.jsonl`), and optional role overrides (`device_roles.json`). - `.env` holds dev runtime settings; `.env.example` mirrors template defaults. +- `VERSION.txt` tracks the current version; append changes in `VERSIONS.md`. ## Build, Test, and Development Commands - `docker compose up -d --build` rebuilds and restarts the backend (preferred workflow). @@ -46,6 +47,9 @@ - `MAP_RADIUS_SHOW=true` draws a debug circle centered on `MAP_START_LAT/LON`. - Set `TRAIL_LEN=0` to disable trails entirely; the HUD trail hint is removed when trails are off. - Coverage button only appears when `COVERAGE_API_URL` is set. +- Optional custom HUD link appears when `CUSTOM_LINK_URL` is set. +- Update banner uses `GIT_CHECK_ENABLED` (compare local vs upstream) with `GIT_CHECK_PATH` pointing at a git repo. +- `GIT_CHECK_FETCH` controls whether the server fetches before comparing; `GIT_CHECK_INTERVAL_SECONDS` sets the recheck interval. - Route history modes default to `path,direct,fanout` via `ROUTE_HISTORY_ALLOWED_MODES`. - `ROUTE_PATH_MAX_LEN` caps oversized path-hash lists (prevents bogus long routes). - Persisted state in `data/state.json` is loaded on startup; edit with care. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4835a2d..404f120 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,10 @@ Thanks for helping improve the MeshCore Live Map. This repo is intentionally lig 2) Rebuild: `docker compose up -d --build` 3) Verify: `curl -s http://localhost:8080/snapshot` +## Versioning +- Update `VERSION.txt` when adding features. +- Append a new section to `VERSIONS.md` describing the change set. + ## Project Layout - `backend/app.py` handles MQTT ingest, decoding, routing, and API endpoints. - `backend/static/index.html` is the HTML shell + template placeholders. @@ -26,6 +30,8 @@ Thanks for helping improve the MeshCore Live Map. This repo is intentionally lig - If `COVERAGE_API_URL` is blank, confirm the Coverage button is hidden. - Note: coordinates at `0,0` (even as strings) are filtered and won’t render. - Radius filter: `MAP_RADIUS_KM` defaults to 241.4 km (150mi); set `0` to disable. +- `CUSTOM_LINK_URL` shows an extra HUD link when set; leave blank to hide. +- Update banner uses `GIT_CHECK_ENABLED` + `GIT_CHECK_PATH` to compare local vs upstream. ## UI Changes When adding UI controls: diff --git a/README.md b/README.md index fc5c50f..ee8c90a 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Mesh Live Map +Version: `1.0.1` (see `VERSION.txt`) + Live MeshCore traffic map that renders nodes, routes, and activity in real time on a Leaflet map. The backend subscribes to MQTT over WebSockets + TLS, decodes MeshCore packets with `@michaelhart/meshcore-decoder`, and streams updates to the browser via WebSockets. Live example sites: - https://live.bostonme.sh/ - Greater Boston Mesh Map - https://map.eastmesh.au/ - Aus Eastern Mesh Live Map - https://mesh-map.e-l33t.org/ - NSW Mesh - Live Mesh Traffic Map -Update check marker: 2025-01-11 ![Live map preview](example.gif) @@ -22,6 +23,7 @@ Update check marker: 2025-01-11 - 24-hour route history tool with volume-based coloring, click-to-view packet details, a heat-band slider, and a link-size slider - Peers tool showing incoming/outgoing neighbors with on-map lines - Coverage layer from a coverage map API (button hidden when not configured) +- Update available banner (git local vs upstream) with dismiss - UI controls: legend toggle, dark map, topo map, units toggle (km/mi), labels toggle, hide nodes, heat toggle - Share button that copies a URL with current view + settings - URL parameters to open the map at a specific view (center, zoom, toggles) @@ -84,6 +86,7 @@ Site metadata (page title + embeds): - `SITE_URL` (public URL) - `SITE_ICON` - `SITE_FEED_NOTE` +- `CUSTOM_LINK_URL` (optional extra HUD link; hidden when blank) - `DISTANCE_UNITS` (`km` or `mi`, default display units) - `NODE_MARKER_RADIUS` (default node marker size in pixels) @@ -124,6 +127,12 @@ Heat + online status: - `MQTT_SEEN_BROADCAST_MIN_SECONDS` - `MQTT_ONLINE_FORCE_NAMES` (comma-separated names to force as MQTT online; also excluded from peers) +Update checks: +- `GIT_CHECK_ENABLED` (show update banner if repo is behind) +- `GIT_CHECK_FETCH` (fetch before comparing) +- `GIT_CHECK_PATH` (path to git repo in the container) +- `GIT_CHECK_INTERVAL_SECONDS` (defaults to 43200 = 12h) + Map + LOS: - `MAP_START_LAT` / `MAP_START_LON` / `MAP_START_ZOOM` (default map view) - `MAP_DEFAULT_LAYER` (`light`, `dark`, or `topo`; localStorage overrides) diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..7dea76e --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +1.0.1 diff --git a/VERSIONS.md b/VERSIONS.md new file mode 100644 index 0000000..9267b6e --- /dev/null +++ b/VERSIONS.md @@ -0,0 +1,22 @@ +# Versions + +## v1.0.1 (01-11-2025) +- Update check banner (git local vs upstream) with dismiss + auto recheck every 12 hours +- Custom HUD link button (configurable via env, hidden when unset) +- Update banner rendered from HTML dataset to avoid JS/token fetch issues +- Git repo mounted into container for update checks; safe.directory configured automatically +- Update banner Hide button styled to match HUD controls + +## v1.0.0 (01-10-2025) +- Live MeshCore node map with MQTT ingest, websocket updates, and Leaflet UI +- Node markers with roles, names, and MQTT online ring +- Trace/path, message, and advert route lines with animations +- Heatmap for recent activity (toggle + intensity slider) +- 24h history tool with heat filter + link weight slider +- Peers tool showing inbound/outbound neighbors with map lines +- LOS tool with elevation profile, peaks, relay suggestion, and mobile support +- Propagation tool with right-side panel and map overlay +- Search, labels toggle, hide nodes, map layer toggles, and shareable URL params +- Distance unit toggle (km/mi) with per-user preference +- PWA install support (manifest + service worker) +- Persistent state + route history on disk diff --git a/backend/Dockerfile b/backend/Dockerfile index 37d7284..bd99667 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12-slim WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends nodejs npm \ + && apt-get install -y --no-install-recommends nodejs npm git \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/app.py b/backend/app.py index 58ac3b9..441cbb5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,6 +3,7 @@ import json import os import html import time +import subprocess from datetime import datetime, timezone from dataclasses import asdict from typing import Any, Dict, Optional, Set, List @@ -98,6 +99,11 @@ from config import ( SITE_URL, SITE_ICON, SITE_FEED_NOTE, + CUSTOM_LINK_URL, + GIT_CHECK_ENABLED, + GIT_CHECK_FETCH, + GIT_CHECK_PATH, + GIT_CHECK_INTERVAL_SECONDS, DISTANCE_UNITS, NODE_MARKER_RADIUS, HISTORY_LINK_SCALE, @@ -154,6 +160,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static") mqtt_client: Optional[mqtt.Client] = None clients: Set[WebSocket] = set() update_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue() +git_update_info = { + "available": False, + "local": None, + "remote": None, + "local_short": None, + "remote_short": None, + "error": None, +} # ========================= # Helpers: coordinate hunting @@ -192,6 +206,71 @@ def _serialize_state() -> Dict[str, Any]: } +def _check_git_updates() -> None: + if not GIT_CHECK_ENABLED: + return + + if not GIT_CHECK_PATH or not os.path.isdir(GIT_CHECK_PATH): + git_update_info["error"] = "git_path_missing" + return + + def _run_git(args: List[str]) -> str: + result = subprocess.run( + args, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return result.stdout.strip() + + try: + subprocess.run( + ["git", "config", "--global", "--add", "safe.directory", GIT_CHECK_PATH], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + inside = _run_git(["git", "-C", GIT_CHECK_PATH, "rev-parse", "--is-inside-work-tree"]) + if inside.lower() != "true": + git_update_info["error"] = "not_git_repo" + return + except Exception: + git_update_info["error"] = "git_unavailable" + return + + try: + if GIT_CHECK_FETCH: + subprocess.run( + ["git", "-C", GIT_CHECK_PATH, "fetch", "--quiet", "--prune"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + local_sha = _run_git(["git", "-C", GIT_CHECK_PATH, "rev-parse", "HEAD"]) + remote_sha = _run_git(["git", "-C", GIT_CHECK_PATH, "rev-parse", "@{u}"]) + git_update_info["local"] = local_sha + git_update_info["remote"] = remote_sha + git_update_info["local_short"] = local_sha[:7] + git_update_info["remote_short"] = remote_sha[:7] + git_update_info["available"] = local_sha != remote_sha + if git_update_info["available"]: + print(f"[update] available {git_update_info['local_short']} -> {git_update_info['remote_short']}") + except Exception: + git_update_info["error"] = "git_compare_failed" + + +async def _git_check_loop() -> None: + if not GIT_CHECK_ENABLED: + return + if GIT_CHECK_INTERVAL_SECONDS <= 0: + return + while True: + await asyncio.sleep(GIT_CHECK_INTERVAL_SECONDS) + _check_git_updates() + + def _device_payload(device_id: str, state: "DeviceState") -> Dict[str, Any]: payload = asdict(state) last_seen = seen_devices.get(device_id) @@ -1069,6 +1148,7 @@ def root(): "SITE_URL": SITE_URL, "SITE_ICON": SITE_ICON, "SITE_FEED_NOTE": SITE_FEED_NOTE, + "CUSTOM_LINK_URL": CUSTOM_LINK_URL, "DISTANCE_UNITS": DISTANCE_UNITS, "NODE_MARKER_RADIUS": NODE_MARKER_RADIUS, "HISTORY_LINK_SCALE": HISTORY_LINK_SCALE, @@ -1088,6 +1168,10 @@ def root(): "LOS_PEAKS_MAX": LOS_PEAKS_MAX, "MQTT_ONLINE_SECONDS": MQTT_ONLINE_SECONDS, "COVERAGE_API_URL": COVERAGE_API_URL, + "UPDATE_AVAILABLE": str(bool(git_update_info.get("available"))).lower(), + "UPDATE_LOCAL": git_update_info.get("local_short") or "", + "UPDATE_REMOTE": git_update_info.get("remote_short") or "", + "UPDATE_BANNER_HIDDEN": "" if git_update_info.get("available") else "hidden", } for key, value in replacements.items(): safe_value = html.escape(str(value), quote=True) @@ -1147,6 +1231,7 @@ def snapshot(request: Request): "history_edges": [_history_edge_payload(e) for e in route_history_edges.values()], "history_window_seconds": int(max(0, ROUTE_HISTORY_HOURS * 3600)), "heat": _serialize_heat_events(), + "update": git_update_info, "server_time": time.time(), } @@ -1472,6 +1557,7 @@ async def ws_endpoint(ws: WebSocket): "history_edges": [_history_edge_payload(e) for e in route_history_edges.values()], "history_window_seconds": int(max(0, ROUTE_HISTORY_HOURS * 3600)), "heat": _serialize_heat_events(), + "update": git_update_info, })) try: @@ -1495,6 +1581,7 @@ async def startup(): _load_state() _load_route_history() _ensure_node_decoder() + _check_git_updates() loop = asyncio.get_event_loop() transport = "websockets" if MQTT_TRANSPORT == "websockets" else "tcp" @@ -1537,6 +1624,7 @@ async def startup(): asyncio.create_task(reaper()) asyncio.create_task(_state_saver()) asyncio.create_task(_route_history_saver()) + asyncio.create_task(_git_check_loop()) @app.on_event("shutdown") diff --git a/backend/config.py b/backend/config.py index 883123c..7311fa6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -70,6 +70,14 @@ SITE_OG_IMAGE = os.getenv("SITE_OG_IMAGE", "") SITE_URL = os.getenv("SITE_URL", "/") SITE_ICON = os.getenv("SITE_ICON", "/static/logo.png") SITE_FEED_NOTE = os.getenv("SITE_FEED_NOTE", "Feed: Boston MQTT.") +CUSTOM_LINK_URL = os.getenv("CUSTOM_LINK_URL", "").strip() +GIT_CHECK_ENABLED = os.getenv("GIT_CHECK_ENABLED", "false").lower() == "true" +GIT_CHECK_FETCH = os.getenv("GIT_CHECK_FETCH", "false").lower() == "true" +GIT_CHECK_PATH = os.getenv("GIT_CHECK_PATH", os.getcwd()).strip() +try: + GIT_CHECK_INTERVAL_SECONDS = float(os.getenv("GIT_CHECK_INTERVAL_SECONDS", "43200")) +except ValueError: + GIT_CHECK_INTERVAL_SECONDS = 43200.0 DISTANCE_UNITS = os.getenv("DISTANCE_UNITS", "km").strip().lower() if DISTANCE_UNITS not in ("km", "mi"): DISTANCE_UNITS = "km" diff --git a/backend/static/app.js b/backend/static/app.js index ff98364..3a858c2 100644 --- a/backend/static/app.js +++ b/backend/static/app.js @@ -45,6 +45,9 @@ const queryHistoryFilter = parseHistoryFilterParam( queryParams.get('history_filter') || queryParams.get('historyFilter') || queryParams.get('historyfilter') ); + const initialUpdateAvailable = parseBoolParam(config.updateAvailable); + const initialUpdateLocal = (config.updateLocal || '').trim(); + const initialUpdateRemote = (config.updateRemote || '').trim(); const reportError = typeof window.__meshmapReportError === 'function' ? window.__meshmapReportError : (message) => console.warn(message); @@ -142,6 +145,7 @@ const HEAT_TTL_MS = 10 * 60 * 1000; const losLayer = L.layerGroup().addTo(map); const coverageApiUrl = (config.coverageApiUrl || '').trim(); + const customLinkUrl = (config.customLinkUrl || '').trim(); const coverageEnabled = Boolean(coverageApiUrl); const coverageLayer = L.layerGroup(); let coverageVisible = false; @@ -3346,6 +3350,9 @@ fn main(@builtin(global_invocation_id) gid: vec3) { historyWindowSeconds = Number(snap.history_window_seconds); updateHistoryWindowLabel(historyWindowSeconds); } + if (snap.update) { + setUpdateBanner(snap.update); + } setStats(); } catch (e) { console.warn("snapshot failed", e); @@ -3386,6 +3393,9 @@ fn main(@builtin(global_invocation_id) gid: vec3) { historyWindowSeconds = Number(msg.history_window_seconds); updateHistoryWindowLabel(historyWindowSeconds); } + if (msg.update) { + setUpdateBanner(msg.update); + } setStats(); return; } @@ -3509,6 +3519,58 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } + const customLink = document.getElementById('custom-link'); + const updateBanner = document.getElementById('update-banner'); + const updateText = document.getElementById('update-text'); + const updateDismiss = document.getElementById('update-dismiss'); + let updateDismissKey = null; + const setUpdateBanner = (info) => { + if (!updateBanner) return; + if (!info || !info.available) { + updateBanner.hidden = true; + updateDismissKey = null; + return; + } + const remoteKey = info.remote_short || info.remote || 'update'; + updateDismissKey = remoteKey; + const dismissed = localStorage.getItem('meshmapUpdateDismissed'); + if (dismissed && dismissed === remoteKey) { + updateBanner.hidden = true; + return; + } + const localLabel = info.local_short || info.local || 'local'; + const remoteLabel = info.remote_short || info.remote || 'remote'; + if (updateText) { + updateText.textContent = `Update available (${localLabel} \u2192 ${remoteLabel})`; + } + updateBanner.hidden = false; + }; + if (customLink) { + if (customLinkUrl) { + customLink.setAttribute('href', customLinkUrl); + customLink.setAttribute('title', customLinkUrl); + } else { + customLink.remove(); + } + } + if (updateDismiss) { + updateDismiss.addEventListener('click', () => { + if (updateDismissKey) { + localStorage.setItem('meshmapUpdateDismissed', updateDismissKey); + } + if (updateBanner) updateBanner.hidden = true; + }); + } + if (initialUpdateAvailable) { + setUpdateBanner({ + available: true, + local_short: initialUpdateLocal || null, + remote_short: initialUpdateRemote || null, + local: initialUpdateLocal || null, + remote: initialUpdateRemote || null, + }); + } + const shareToggle = document.getElementById('share-toggle'); if (shareToggle) { const resetShareButton = () => { diff --git a/backend/static/index.html b/backend/static/index.html index 4ad130a..ce9f19d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -46,6 +46,10 @@ data-node-radius="{{NODE_MARKER_RADIUS}}" data-history-link-scale="{{HISTORY_LINK_SCALE}}" data-coverage-api-url="{{COVERAGE_API_URL}}" + data-custom-link-url="{{CUSTOM_LINK_URL}}" + data-update-available="{{UPDATE_AVAILABLE}}" + data-update-local="{{UPDATE_LOCAL}}" + data-update-remote="{{UPDATE_REMOTE}}" > - + diff --git a/backend/static/styles.css b/backend/static/styles.css index 5d1bdb4..b3554a7 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -434,4 +434,26 @@ @keyframes route-dash { to { stroke-dashoffset: -200; } } + + .hud-update { + margin-top: 8px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(249, 115, 22, 0.12); + border: 1px solid rgba(249, 115, 22, 0.4); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: #fed7aa; + } + + .hud-update-text { + font-weight: 600; + } + + .hud-update-dismiss { + margin-top: 0; + } diff --git a/docker-compose.yaml b/docker-compose.yaml index eec3b72..fa570c3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,6 +16,11 @@ services: SITE_URL: "${SITE_URL:-/}" SITE_ICON: "${SITE_ICON:-/static/logo.png}" SITE_FEED_NOTE: "${SITE_FEED_NOTE:-Feed: Boston MQTT.}" + CUSTOM_LINK_URL: "${CUSTOM_LINK_URL:-}" + GIT_CHECK_ENABLED: "${GIT_CHECK_ENABLED:-false}" + GIT_CHECK_FETCH: "${GIT_CHECK_FETCH:-false}" + GIT_CHECK_PATH: "${GIT_CHECK_PATH:-/app}" + GIT_CHECK_INTERVAL_SECONDS: "${GIT_CHECK_INTERVAL_SECONDS:-43200}" DISTANCE_UNITS: "${DISTANCE_UNITS:-km}" NODE_MARKER_RADIUS: "${NODE_MARKER_RADIUS:-8}" MQTT_HOST: "${MQTT_HOST:-localhost}" @@ -44,3 +49,4 @@ services: restart: unless-stopped volumes: - ./data:/data + - ./:/repo:ro diff --git a/docs.md b/docs.md index 751e257..5846a80 100644 --- a/docs.md +++ b/docs.md @@ -5,6 +5,10 @@ This document captures the state of the project and the key changes made so far, ## Overview This project renders live MeshCore traffic on a Leaflet + OpenStreetMap map. A FastAPI backend subscribes to MQTT (WSS/TLS), decodes MeshCore packets using `@michaelhart/meshcore-decoder`, and broadcasts device updates and routes over WebSockets to the frontend. Core logic is split into config/state/decoder/LOS/history modules so changes are localized. The UI includes heatmap, LOS tools, map mode toggles, and a 24‑hour route history layer. +## Versioning +- `VERSION.txt` holds the current version string. +- `VERSIONS.md` is an append-only changelog by version. + ## Key Paths - `backend/app.py`: FastAPI server + MQTT lifecycle and websocket broadcasting. - `backend/config.py`: environment/config constants (shared across backend modules). @@ -29,6 +33,12 @@ This project renders live MeshCore traffic on a Leaflet + OpenStreetMap map. A F - `curl -s http://localhost:8080/debug/last` (recent MQTT decode/debug entries). - `curl -s http://localhost:8080/peers/` (peer counts for a node; uses route history). +## Env Notes (Recent Additions) +- `CUSTOM_LINK_URL` adds a HUD link button; blank hides it. +- `MQTT_ONLINE_FORCE_NAMES` forces named nodes to show MQTT online and skips them in peers. +- `GIT_CHECK_ENABLED`, `GIT_CHECK_FETCH`, `GIT_CHECK_PATH` enable update checks. +- `GIT_CHECK_INTERVAL_SECONDS` controls how often the server re-checks for updates. + ## MQTT + Decoder - MQTT is **WebSockets + TLS** (`MQTT_TRANSPORT=websockets`, `MQTT_TLS=true`, `MQTT_WS_PATH=/` or `/mqtt`). - Decoder uses Node + `@michaelhart/meshcore-decoder` installed in the container. @@ -66,6 +76,8 @@ This project renders live MeshCore traffic on a Leaflet + OpenStreetMap map. A F - PWA install support is enabled via `/manifest.webmanifest` and a service worker at `/sw.js`. - Clicking the HUD logo hides/shows the left panel while tool panels stay open. - Share button copies a URL with the current view + toggles (including HUD visibility). +- Optional custom HUD link appears when `CUSTOM_LINK_URL` is set. +- Update banner shows when `GIT_CHECK_ENABLED=true` and the repo is behind; users can dismiss it per remote SHA. - URL params override stored settings: `lat`, `lon`/`lng`/`long`, `zoom`, `layer`, `history`, `heat`, `labels`, `nodes`, `legend`, `menu`, `units`, `history_filter`. - Service worker uses `no-store` for navigation requests so env-driven UI toggles (like the radius ring) update without clearing site data.