From 8865a6444de39ccc5305e1d9770e86451cef8192 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 4 Mar 2026 11:16:26 +0000 Subject: [PATCH] Add ukmesh.com frontend and MQTT infrastructure www.ukmesh.com (website-ukmesh container, port 3004): - UKLayout, UKHomePage, UKInstallPage, UKMqttPage components - VITE_SITE=ukmesh build arg routes to UK pages in main.tsx - Stats fetched via relative /api path (proxied by Nginx) app.ukmesh.com (app-ukmesh container, port 3003): - Same dashboard build with VITE_APP_HOSTNAME=app.ukmesh.com - VITE_NETWORK=ukmesh for future API filtering MQTT: - node-ukmesh Mosquitto user for ukmesh observer nodes - ACL updated: backend reads meshcore/# and ukmesh/# - ukmesh nodes publish to ukmesh/{IATA}/{PUBKEY}/packets Cloudflare routes to add in Zero Trust: www.ukmesh.com -> http://website-ukmesh:80 app.ukmesh.com -> http://app-ukmesh:80 mqtt.ukmesh.com -> http://mosquitto:9001 Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.app | 2 + Dockerfile.website | 2 + docker-compose.yml | 29 +++ frontend/src/App.tsx | 32 ++- frontend/src/main.tsx | 25 ++- frontend/src/pages/ukmesh/UKHomePage.tsx | 192 ++++++++++++++++++ frontend/src/pages/ukmesh/UKInstallPage.tsx | 153 ++++++++++++++ frontend/src/pages/ukmesh/UKLayout.tsx | 64 ++++++ frontend/src/pages/ukmesh/UKMqttPage.tsx | 211 ++++++++++++++++++++ frontend/src/vite-env.d.ts | 10 + mosquitto/acl | 10 +- 11 files changed, 721 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/ukmesh/UKHomePage.tsx create mode 100644 frontend/src/pages/ukmesh/UKInstallPage.tsx create mode 100644 frontend/src/pages/ukmesh/UKLayout.tsx create mode 100644 frontend/src/pages/ukmesh/UKMqttPage.tsx diff --git a/Dockerfile.app b/Dockerfile.app index 9042562..5f329a1 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -5,7 +5,9 @@ COPY frontend/package.json ./ RUN npm install COPY frontend/ ./ ARG VITE_APP_HOSTNAME +ARG VITE_NETWORK=teesside ENV VITE_APP_HOSTNAME=$VITE_APP_HOSTNAME +ENV VITE_NETWORK=$VITE_NETWORK RUN npm run build FROM nginx:alpine diff --git a/Dockerfile.website b/Dockerfile.website index 079c6d1..6348678 100644 --- a/Dockerfile.website +++ b/Dockerfile.website @@ -4,7 +4,9 @@ COPY frontend/package.json ./ RUN npm install COPY frontend/ ./ ARG VITE_APP_HOSTNAME +ARG VITE_SITE=teesside ENV VITE_APP_HOSTNAME=$VITE_APP_HOSTNAME +ENV VITE_SITE=$VITE_SITE RUN npm run build FROM nginx:alpine diff --git a/docker-compose.yml b/docker-compose.yml index bc6804d..5c9a2c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,12 +103,39 @@ services: dockerfile: Dockerfile.website args: VITE_APP_HOSTNAME: ${VITE_APP_HOSTNAME:-} + VITE_SITE: teesside restart: unless-stopped ports: - "3002:80" depends_on: - backend + app-ukmesh: + build: + context: . + dockerfile: Dockerfile.app + args: + VITE_APP_HOSTNAME: app.ukmesh.com + VITE_NETWORK: ukmesh + restart: unless-stopped + depends_on: + - backend + ports: + - "3003:80" + + website-ukmesh: + build: + context: . + dockerfile: Dockerfile.website + args: + VITE_APP_HOSTNAME: app.ukmesh.com + VITE_SITE: ukmesh + restart: unless-stopped + ports: + - "3004:80" + depends_on: + - backend + cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped @@ -120,6 +147,8 @@ services: - backend - app - website + - app-ukmesh + - website-ukmesh - mosquitto volumes: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2db056..ab8926b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -116,6 +116,32 @@ function distKm(a: MeshNode, b: MeshNode): number { return Math.hypot(dlat, dlon); } +// ── Line-of-sight check (smooth Earth, k=0.25 refraction) ──────────────────── +const R_EFF_M = 6_371_000 / (1 - 0.25); // ~8,495,000 m effective Earth radius + +/** + * Smooth-Earth LOS check with atmospheric refraction k=0.25 (equivalent to + * the standard 4/3-Earth model). Uses each node's elevation_m (terrain ASL) + * plus 5 m antenna height. Samples 20 evenly-spaced points along the path + * and rejects the hop if Earth curvature alone would block the ray at any + * point. Does not model terrain between the nodes — confirmed links already + * have full ITM terrain analysis; this guards unconfirmed candidates. + */ +function hasLoS(a: MeshNode, b: MeshNode): boolean { + const hA = (a.elevation_m ?? 0) + 5; // metres ASL at antenna + const hB = (b.elevation_m ?? 0) + 5; + const d = distKm(a, b) * 1000; // metres + if (d < 1) return true; + for (let i = 1; i < 20; i++) { + const t = i / 20; + const x = t * d; + const los = hA + (hB - hA) * t; + const bulge = x * (d - x) / (2 * R_EFF_M); + if (los < bulge) return false; + } + return true; +} + // Fallback range check (used when no ITM data exists for a pair). function nodeRange(nodeId: string, coverage: NodeCoverage[]): number { const cov = coverage.find((c) => c.node_id === nodeId); @@ -169,15 +195,15 @@ function resolveBetaPath( .map((c) => { usedIds.add(c.node_id); return { node: c, conf: 0.9 }; }); const reachable = all - .filter((c) => !usedIds.has(c.node_id) && canReach(c, prevNode, coverage)) + .filter((c) => !usedIds.has(c.node_id) && canReach(c, prevNode, coverage) && hasLoS(c, prevNode)) .sort((a, b) => distKm(a, prevNode) - distKm(b, prevNode)) .slice(0, 2) .map((c) => { usedIds.add(c.node_id); return { node: c, conf: 0.3 / Math.max(1, all.length) }; }); - // Last resort: closest prefix match within 150 km, even if outside coverage radius. + // Last resort: closest prefix match within 50 km with LOS clearance. // Prevents hops being silently dropped just because link data is sparse. const fallback = all - .filter((c) => !usedIds.has(c.node_id) && distKm(c, prevNode) < 150) + .filter((c) => !usedIds.has(c.node_id) && distKm(c, prevNode) < 50 && hasLoS(c, prevNode)) .sort((a, b) => distKm(a, prevNode) - distKm(b, prevNode)) .slice(0, 1) .map((c) => ({ node: c, conf: 0.05 / Math.max(1, all.length) })); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 29be0b1..4f5b900 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,19 +10,38 @@ import { OpenSourcePage } from './pages/OpenSourcePage.js'; import { MqttPage } from './pages/MqttPage.js'; import { StatsPage } from './pages/StatsPage.js'; import { PacketsPage } from './pages/PacketsPage.js'; +import { UKLayout } from './pages/ukmesh/UKLayout.js'; +import { UKHomePage } from './pages/ukmesh/UKHomePage.js'; +import { UKInstallPage } from './pages/ukmesh/UKInstallPage.js'; +import { UKMqttPage } from './pages/ukmesh/UKMqttPage.js'; import './styles/globals.css'; const root = document.getElementById('root')!; const { hostname } = window.location; const APP_HOSTNAME = import.meta.env['VITE_APP_HOSTNAME']; -const isAppDomain = !APP_HOSTNAME || hostname === APP_HOSTNAME; +const SITE = import.meta.env['VITE_SITE'] ?? 'teesside'; +const isAppDomain = !APP_HOSTNAME || hostname === APP_HOSTNAME; -document.title = isAppDomain ? 'MeshCore Analytics' : 'Teesside Mesh'; +document.title = isAppDomain + ? 'MeshCore Analytics' + : SITE === 'ukmesh' ? 'UK Mesh Network' : 'Teesside Mesh'; ReactDOM.createRoot(root).render( {isAppDomain ? ( + ) : SITE === 'ukmesh' ? ( + + + }> + } /> + } /> + } /> + } /> + } /> + + + ) : ( @@ -31,8 +50,8 @@ ReactDOM.createRoot(root).render( } /> } /> } /> - } /> } /> + } /> } /> diff --git a/frontend/src/pages/ukmesh/UKHomePage.tsx b/frontend/src/pages/ukmesh/UKHomePage.tsx new file mode 100644 index 0000000..921e521 --- /dev/null +++ b/frontend/src/pages/ukmesh/UKHomePage.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Link } from 'react-router-dom'; + +interface SiteStats { + packetsDay: number; + totalNodes: number; + longestHop: number; + longestHopHash: string | null; +} + +function useFlash(value: number) { + const [flash, setFlash] = useState(false); + const prev = useRef(value); + useEffect(() => { + if (value !== prev.current) { + prev.current = value; + setFlash(true); + const t = setTimeout(() => setFlash(false), 600); + return () => clearTimeout(t); + } + }, [value]); + return flash; +} + +const StatCard: React.FC<{ value: number; label: string; suffix?: string; format?: (n: number) => string }> = ({ + value, label, suffix = '', format, +}) => { + const flash = useFlash(value); + const display = format ? format(value) : value.toLocaleString(); + return ( +
+ + {display}{suffix && {suffix}} + + {label} +
+ ); +}; + +export const UKHomePage: React.FC = () => { + const [stats, setStats] = useState({ packetsDay: 0, totalNodes: 0, longestHop: 0, longestHopHash: null }); + const hopFlash = useFlash(stats.longestHop); + + useEffect(() => { + const fetch_ = () => + fetch('/api/stats') + .then(r => r.json()) + .then(d => setStats({ packetsDay: d.packetsDay, totalNodes: d.totalNodes, longestHop: d.longestHop, longestHopHash: d.longestHopHash ?? null })) + .catch(() => {}); + fetch_(); + const t = setInterval(fetch_, 30_000); + return () => clearInterval(t); + }, []); + + return ( + <> + {/* ── Hero ─────────────────────────────────────────────────────── */} +
+
+
+
United Kingdom · LoRa 868 MHz
+

+ UK
+ Mesh Network +

+

+ A UK-wide off-grid communications network built on{' '} + MeshCore,{' '} + a free, open-source LoRa mesh platform. No internet. No infrastructure. Just radio. +

+
+ Open Live Map → + Learn more +
+
+
+ + {/* ── Live stats ───────────────────────────────────────────────── */} +
+
+

Live network stats · updates every 30s

+
+ + +
+ + {stats.longestHop.toLocaleString()} hops + + Longest relay chain ever recorded + {stats.longestHopHash && ( + + {stats.longestHopHash} + + )} +
+
+
+
+ + {/* ── Radio config ─────────────────────────────────────────────── */} +
+
+

Network radio configuration

+
+
+ EU/UK Narrow + Profile +
+
+ 869.618 + Frequency (MHz) +
+
+ 62.5kHz + Bandwidth +
+
+ SF8 + Spreading Factor +
+
+ CR8 + Coding Rate +
+
+
+
+ + {/* ── About cards ─────────────────────────────────────────────── */} +
+
+ +
+
📡
+

What is MeshCore?

+

+ MeshCore is open-source firmware for ESP32 LoRa hardware. Each node acts as + both a radio and a relay. Packets hop between nodes automatically, extending + range far beyond what a single radio can achieve. +

+ Learn more → +
+ +
+
🔧
+

Become an observer

+

+ Connect your repeater node to the UK Mesh MQTT broker and contribute live + packet data from your area. All you need is a Linux device, a USB cable, + and about 15 minutes. +

+ Observer setup → +
+ +
+
🗺️
+

Live map and analytics

+

+ The live dashboard shows every packet heard across the UK in real time: node + positions, relay paths, RF coverage, and a decoded packet feed. Built entirely + on open source tools. +

+ Open map → +
+ +
+
+ + {/* ── Discord CTA ─────────────────────────────────────────────── */} +
+
+
+

Join the conversation

+

+ We hang out on the MeshCore Discord. Come say hello, ask questions, or + coordinate coverage with other UK operators. DM ibengr to + get set up as an observer. +

+
+ + Join Discord → + +
+
+ + ); +}; diff --git a/frontend/src/pages/ukmesh/UKInstallPage.tsx b/frontend/src/pages/ukmesh/UKInstallPage.tsx new file mode 100644 index 0000000..a933f5f --- /dev/null +++ b/frontend/src/pages/ukmesh/UKInstallPage.tsx @@ -0,0 +1,153 @@ +import React from 'react'; + +export const UKInstallPage: React.FC = () => ( + <> +
+
+

Install MeshCore

+

+ Get a companion node on the air in about 10 minutes. No soldering, no special tools. Just a browser, a USB cable, and a phone. +

+
+
+ +
+ +
+

+ 1 + What you need +

+

+ A companion node is a handheld or portable device you use to send and receive messages on the mesh. + Any ESP32-based LoRa board supported by MeshCore will work. The most common choice in the UK + is the Heltec WiFi LoRa 32 V4. It has a built-in OLED display, + USB-C charging, integrated battery management, and costs around £25–£35. +

+ +
+
+
Recommended
+
Heltec WiFi LoRa 32 V4
+
ESP32-S3 · 868 MHz · OLED · USB-C · Li-Po
+
+
+
Heltec WiFi LoRa 32 V3
+
ESP32 · 868 MHz · OLED · USB-C
+
+
+
LILYGO T3S3
+
ESP32-S3 · 868 MHz · E-Paper optional
+
+
+
Heltec Mesh Node T114
+
nRF52840 · 868 MHz · Ultra-compact
+
+
+ +

+ You will also need: a USB cable to match your board (USB-C for V4/V3), an Android + phone to run the MeshCore companion app, and optionally a 3.7 V Li-Po battery to run the board untethered. +

+
+ +
+

+ 2 + Flash the firmware +

+

+ Everything is done in the browser, no software to install. You need Chrome or Edge (Web Serial API required). +

+
    +
  1. + Connect your board to your PC via USB and open the{' '} + + MeshCore web flasher + . +
  2. +
  3. + Select your device hardware from the dropdown, then select{' '} + Companion Radio (Bluetooth) as the firmware type. +
  4. +
  5. + Click Enter DFU Mode, then Erase Flash, then Flash. + The flash takes about 30 seconds. +
  6. +
  7. + Once complete, your board will reboot and show the MeshCore splash screen on its OLED (if it has one). +
  8. +
+

+ If your board does not appear in the port list, try holding the BOOT button + while plugging in the USB cable to enter bootloader mode. +

+
+ +
+

+ 3 + Configure your node +

+

+ Install the MeshCore companion app on your Android phone + (search for "MeshCore" on Google Play). iOS support is in development. +

+
    +
  1. + Open the MeshCore app and tap Add device. Your node will appear in the + Bluetooth scan. +
  2. +
  3. + Set a node name. Your callsign, name, or location works well. +
  4. +
  5. + Set the device role to Client for a handheld companion node. +
  6. +
  7. + Set the radio to the UK network configuration: +
    + Profile: EU/UK Narrow  |  + Freq: 869.618 MHz  |  + BW: 62.5 kHz  |  + SF8 / CR8 +
  8. +
  9. + Leave the channel set to the default Public channel. +
  10. +
  11. + Add your GPS coordinates so the network can place you on the map. +
  12. +
+
+ +
+

+ 4 + Get on the network +

+

+ If your node is on and configured to the Public channel, and within range of any + UK Mesh repeater, you should be able to send and receive messages straight away. + Send a message in the Public channel. If it shows "Heard X Repeats" instead + of just "Sent", you are on the network. +

+

+ Check the{' '} + live map{' '} + to see if your node appears. Come say hello on the MeshCore Discord. + DM ibengr if you have any questions. +

+ + Join us on Discord → + +
+ +
+ +); diff --git a/frontend/src/pages/ukmesh/UKLayout.tsx b/frontend/src/pages/ukmesh/UKLayout.tsx new file mode 100644 index 0000000..c3846b7 --- /dev/null +++ b/frontend/src/pages/ukmesh/UKLayout.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { NavLink, Outlet, Link, useNavigate } from 'react-router-dom'; + +const APP_URL = 'https://app.ukmesh.com'; + +export const UKLayout: React.FC = () => { + const [menuOpen, setMenuOpen] = useState(false); + const navigate = useNavigate(); + + const closeMenu = () => setMenuOpen(false); + const handleNavClick = (to: string) => { closeMenu(); navigate(to); }; + + return ( +
+ + +
+ +
+ + +
+ ); +}; diff --git a/frontend/src/pages/ukmesh/UKMqttPage.tsx b/frontend/src/pages/ukmesh/UKMqttPage.tsx new file mode 100644 index 0000000..405989f --- /dev/null +++ b/frontend/src/pages/ukmesh/UKMqttPage.tsx @@ -0,0 +1,211 @@ +import React from 'react'; + +export const UKMqttPage: React.FC = () => ( + <> +
+
+

MQTT Observer Setup

+

+ Connect your repeater node to the UK Mesh MQTT broker and contribute live packet + data to the national dashboard. +

+
+
+ +
+ +
+

What is this?

+

+ meshcoretomqtt is software that runs on a Linux device (such as a + Raspberry Pi) connected to your MeshCore repeater node via USB. It reads the packet + stream from the node's serial port and publishes it to an MQTT broker over the internet. +

+

+ Once connected, every packet your node hears will appear on the live map in real time, + your node's position and RF coverage will be shown, and you will be contributing to the + UK-wide picture of the network. +

+
+ Access is by request. Message ibengr on the{' '} + Discord{' '} + to get your credentials before going through this setup. +
+
+ +
+

+ 1 + Flash firmware with packet logging +

+

+ We use the pre-built firmware from the team at{' '} + letsmesh, + a great project for global MeshCore network stats. You will need their firmware with + packet logging enabled. The current version is 1.13.0. +

+
    +
  1. + Go to the{' '} + + letsmesh firmware flasher + {' '} + and select your device variant. +
  2. +
  3. Choose Custom as the flash option to get the packet logging build.
  4. +
  5. + Connect your node via USB and flash it using the web flasher. No software install + required, just Chrome or Edge. +
  6. +
+

+ This only applies if you are setting up a fresh node. If your repeater is already running + 1.13.0 from letsmesh it likely already has packet logging enabled. +

+
+ +
+

+ 2 + Run the installer +

+

+ Connect your node to your Raspberry Pi (or other Linux device) via USB, then run the + install script: +

+
+
{'curl -fsSL https://raw.githubusercontent.com/Cisien/meshcoretomqtt/main/install.sh | bash'}
+
+

+ meshcoretomqtt is for Repeater or Room Server nodes only. +

+

+ The script will walk you through setup interactively. Key choices to make: +

+ +

Enable LetsMesh Packet Analyzer

+

+ Choose y. This enables packet logging and registers your node with + the letsmesh global stats platform. +

+
+
{'Enable LetsMesh Packet Analyzer MQTT servers? [Y/n]: y'}
+
+ +

IATA region code

+

+ Enter the three-letter IATA code for your nearest commercial airport. Some examples: +

+
+
+ MME + Teesside +
+
+ NCL + Newcastle +
+
+ MAN + Manchester +
+
+ LHR + London +
+
+ LBA + Leeds +
+
+ BHX + Birmingham +
+
+

+ Use the correct IATA code for your location. A quick search will confirm the right one. +

+ +

Add the UK Mesh broker

+

+ When asked if you'd like to configure additional MQTT brokers, choose y{' '} + and add 1 broker. Enter the following details using the credentials + you received from ibengr: +

+
+
{`Server hostname/IP: mqtt.ukmesh.com
+Port [1883]: 443
+Use WebSockets transport? [y/N]: y
+Use TLS/SSL encryption? [y/N]: y
+Verify TLS certificates? [Y/n]: y
+Choose authentication method [1-3] [1]: 1
+Username: 
+Password: `}
+
+ +

Custom topic

+

+ The UK Mesh broker uses a different topic prefix from the default. After the installer + finishes, add the following to your config at{' '} + ~/.meshcoretomqtt/.env.local (replacing MQTT3_ with whichever + broker slot you used): +

+
+
{'MCTOMQTT_MQTT3_TOPIC_PACKETS=ukmesh/{IATA}/{PUBLIC_KEY}/packets'}
+
+

+ Then restart the service: +

+
+
{'sudo systemctl restart mctomqtt'}
+
+

+ Your node should appear on the{' '} + live map{' '} + within a few minutes once an advert packet is heard. +

+
+ +
+

Tips for a stable setup

+
    +
  • + Use a good USB cable. Cheap or long cables cause intermittent serial + disconnects. Keep the node physically away from the Pi to avoid RF noise on 868 MHz. +
  • +
  • + Disable USB autosuspend for the node. Find your device vendor ID with{' '} + lsusb, then create a udev rule: +
    +
    {'ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="XXXX", ATTR{power/autosuspend}="-1"'}
    +
    +
  • +
  • + If the node stops sending data, check{' '} + sudo journalctl -u mctomqtt -n 20. If the serial watchdog is cycling, + run sudo systemctl restart mctomqtt. If the serial port has disappeared, + reseat the USB cable on the node. +
  • +
+
+ +
+

Get access

+

+ Message ibengr on the MeshCore Discord with your node name, location, + and IATA code and we will get you set up with credentials for the UK Mesh broker. +

+ + Join Discord → + +
+ +
+ +); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..1331e90 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,11 @@ /// + +interface ImportMetaEnv { + readonly VITE_APP_HOSTNAME: string; + readonly VITE_SITE: string; // 'teesside' | 'ukmesh' + readonly VITE_NETWORK: string; // 'teesside' | 'ukmesh' +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/mosquitto/acl b/mosquitto/acl index 40f786e..c683e57 100644 --- a/mosquitto/acl +++ b/mosquitto/acl @@ -1,8 +1,12 @@ -# backend service — subscribe to everything (used by the analytics app) +# backend service — subscribe to all network topic prefixes user backend topic readwrite meshcore/# +topic readwrite ukmesh/# -# node observers — publish to meshcore/{IATA}/{PUBKEY}/packets -# IATA and pubkey are not known at registration time so allow the full namespace +# teesside node observers — publish to meshcore/{IATA}/{PUBKEY}/packets user node1 topic write meshcore/# + +# ukmesh node observers — publish to ukmesh/{IATA}/{PUBKEY}/packets +user node-ukmesh +topic write ukmesh/#