Sync dev updates and versioning

This commit is contained in:
yellowcooln 2026-01-11 21:22:32 +00:00
parent 21fd17396c
commit f360e64fed
14 changed files with 264 additions and 3 deletions

View file

@ -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

View file

@ -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.

View file

@ -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 wont 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:

View file

@ -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)

1
VERSION.txt Normal file
View file

@ -0,0 +1 @@
1.0.1

22
VERSIONS.md Normal file
View file

@ -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

View file

@ -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 .

View file

@ -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")

View file

@ -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"

View file

@ -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<u32>) {
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<u32>) {
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<u32>) {
}
}
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 = () => {

View file

@ -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}}"
>
<script>
window.__meshmapStarted = false;
@ -64,6 +68,12 @@
<path d="M18 16.1a2.9 2.9 0 0 0-1.95.77l-7.1-4.14c.04-.23.05-.46.05-.7 0-.23-.01-.46-.05-.68l7.05-4.12A2.95 2.95 0 1 0 14.5 5c0 .23.02.46.07.68L7.52 9.8a2.9 2.9 0 1 0 0 4.4l7.05 4.12a2.9 2.9 0 1 0 3.43-2.22z"/>
</svg>
</button>
<a class="hud-action hud-custom" id="custom-link" href="{{CUSTOM_LINK_URL}}" target="_blank" rel="noopener" aria-label="Open custom link">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 3h7v7h-2V6.41l-9.3 9.3-1.4-1.42 9.3-9.29H14V3z"/>
<path d="M5 5h6v2H7v10h10v-4h2v6H5V5z"/>
</svg>
</a>
<a class="hud-action hud-github" href="https://github.com/yellowcooln/meshcore-mqtt-live-map/" target="_blank" rel="noopener" aria-label="GitHub repository">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.23.68-.5v-1.77c-2.78.62-3.37-1.38-3.37-1.38-.46-1.2-1.12-1.52-1.12-1.52-.9-.64.07-.63.07-.63 1 .07 1.53 1.07 1.53 1.07.9 1.6 2.36 1.14 2.94.87.1-.66.35-1.14.63-1.4-2.22-.27-4.56-1.14-4.56-5.08 0-1.12.39-2.04 1.02-2.76-.1-.27-.45-1.37.1-2.86 0 0 .83-.27 2.72 1.05a9.2 9.2 0 0 1 2.48-.35c.84 0 1.68.12 2.48.35 1.88-1.32 2.7-1.05 2.7-1.05.56 1.5.21 2.6.11 2.86.64.72 1.02 1.64 1.02 2.76 0 3.95-2.34 4.8-4.57 5.07.36.33.68.97.68 1.96v2.9c0 .28.18.6.69.5A10.03 10.03 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/>
@ -74,6 +84,10 @@
<div class="small">MeshCore live map • markers update in real time{{TRAIL_INFO_SUFFIX}} • click logo to hide/show HUD elements</div>
<div class="small">{{SITE_FEED_NOTE}}</div>
<div class="small" id="stats"></div>
<div class="hud-update" id="update-banner" {{UPDATE_BANNER_HIDDEN}}>
<span class="hud-update-text" id="update-text">Update available</span>
<button class="map-toggle hud-update-dismiss" id="update-dismiss" type="button">Hide</button>
</div>
<div class="node-search">
<input class="node-search-input" id="node-search" type="text" placeholder="Search nodes by name or key..." autocomplete="off" />
<div class="node-search-results" id="node-search-results" hidden></div>
@ -256,6 +270,6 @@
></script>
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js" crossorigin="anonymous"></script>
<script src="/static/app.js?v=peers1" defer></script>
<script src="/static/app.js?v=peers2" defer></script>
</body>
</html>

View file

@ -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;
}

View file

@ -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

12
docs.md
View file

@ -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 24hour 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/<device_id>` (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.