From 8d8be74f1077f8771d0441d02bff591d60609556 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:49:26 -0400 Subject: [PATCH 1/5] feat(OffbandMesh/meshcore-client#722): add the Offband radio preset overlay and its validator radio-presets/offband.json holds the presets MeshCore's upstream list lacks: USA - Arizona (SF9/CR8, owner decision), USA - Philly Mesh (919.500 / 500 kHz / SF10 / CR5 / 2-byte path hash, phillymesh.net 2026-10-01), USA - OKI-Mesh 500kHz Test (909.75 / 500 / SF10 / CR5), the 27 Russian city presets and the 3 Off-Grid client-repeat frequencies carried over from the app's hardcoded list. scripts/validate_radio_presets.py checks the overlay and the upstream mirror (stdlib only). SCHEMA.md documents both files, precedence, and how to add, change and retire an entry. Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- SCHEMA.md | 77 ++++++ radio-presets/offband.json | 407 ++++++++++++++++++++++++++++++ scripts/validate_radio_presets.py | 236 +++++++++++++++++ 3 files changed, 720 insertions(+) create mode 100644 radio-presets/offband.json create mode 100644 scripts/validate_radio_presets.py diff --git a/SCHEMA.md b/SCHEMA.md index 85a2308..c4017cd 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -91,3 +91,80 @@ A user can point the app at any HTTP source. The tail of the URL decides how it' There is **no** reliance on HTTP directory listing — a directory URL resolves to `/profiles.json`. Host `profiles.json` + your YAMLs on any HTTP server and your catalog behaves exactly like this one. + +## Radio presets: `radio-presets/` + +The app's regional radio preset list (frequency, bandwidth, spreading factor, coding +rate) comes from two files. The app ships with a copy of both, so it works offline, +and refreshes them from here. **An edit here reaches users on their next refresh, +with no app release** (GitHub's raw-file cache can delay it by about five minutes). + +| File | What it is | Who edits it | +|---|---|---| +| `meshcore-upstream.json` | MeshCore's suggested presets, maintained by Liam Cottle, mirrored unmodified from `https://api.meshcore.nz/api/v1/config` | Nobody by hand. A scheduled workflow rewrites it when the upstream list changes | +| `offband.json` | Offband's overlay: presets the upstream list lacks | People, by PR | + +**Precedence:** the app shows both lists. When an overlay entry has the same `title` +as an upstream entry, the overlay entry wins. That must be deliberate, so the entry +must also set `"overrides_upstream": true`; the validator rejects an unmarked clash. + +Check your change before opening a PR: + +``` +python scripts/validate_radio_presets.py +``` + +### Overlay file: `offband.json` + +```json +{ + "format": "offband-radio-presets", + "format_version": 1, + "description": "...", + "presets": [ + { + "id": "usa-philly-mesh", + "title": "USA - Philly Mesh", + "region": "United States", + "frequency": 919.5, + "bandwidth": 500, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power": 20, + "path_hash_size": 2, + "status": "published", + "notes": "Where the values come from, and when they were checked." + } + ] +} +``` + +| Field | Required | Rule | +|---|---|---| +| `id` | yes | Stable key: lowercase letters, digits, single hyphens. Never reuse or change it | +| `title` | yes | Shown in the picker; unique in this file | +| `region` | yes | Picker group, e.g. `United States`, `Russia`, `Off-Grid` | +| `frequency` | yes | MHz, a JSON number, 150 to 2500 | +| `bandwidth` | yes | kHz, one of 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 | +| `spreading_factor` | yes | 5 to 12 | +| `coding_rate` | yes | The denominator of 4/x: 5 to 8 (4/5 is `5`) | +| `tx_power` | no | dBm, whole number, -9 to 30. Omit to leave the radio's power alone | +| `path_hash_size` | no | **Bytes**, 1 to 3. The app converts to the firmware's zero-based mode (2 bytes = mode 1). Omit to leave the radio's setting alone | +| `off_grid` | no | `true` marks a client-repeat (off-grid) frequency | +| `overrides_upstream` | no | `true` when this entry deliberately replaces an upstream entry with the same title | +| `status` | yes | `published` (shown) or `retired` (kept for history, hidden) | +| `notes` | no | Source and date of the values. Not shown in the app | + +Numbers must be JSON numbers, not strings. Unknown keys are rejected. + +**To add** a preset, append an entry with a new `id`. **To change** one, edit its +values in place and update `notes`. **To retire** one, set `status` to `"retired"`; +don't delete it, and never reuse its `id`. + +### Upstream mirror: `meshcore-upstream.json` + +`suggested_radio_settings` is copied byte-for-byte from the API (whose numbers are +strings), wrapped with `format`, `format_version`, `source`, `credit` and +`updated_at` (when the upstream content last changed). The workflow validates the +fetched list before writing; if the API is unreachable or its shape changes, the run +fails and the last good mirror stays in place. diff --git a/radio-presets/offband.json b/radio-presets/offband.json new file mode 100644 index 0000000..c8b4c2f --- /dev/null +++ b/radio-presets/offband.json @@ -0,0 +1,407 @@ +{ + "format": "offband-radio-presets", + "format_version": 1, + "description": "Offband radio preset overlay. These entries are added to the MeshCore preset list mirrored in meshcore-upstream.json, and win over it when a title matches. Edit this file to add, change or retire a preset; the app picks the change up on its next refresh, with no app release. See SCHEMA.md.", + "presets": [ + { + "id": "usa-arizona", + "title": "USA - Arizona", + "region": "United States", + "frequency": 908.205, + "bandwidth": 62.5, + "spreading_factor": 9, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "SF9/CR8 per zjs81/meshcore-open#507 (Tucson / Phoenix alignment). Unconfirmed by an Arizona authority; owner decision 2026-10-01." + }, + { + "id": "usa-philly-mesh", + "title": "USA - Philly Mesh", + "region": "United States", + "frequency": 919.5, + "bandwidth": 500, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power": 20, + "path_hash_size": 2, + "status": "published", + "notes": "phillymesh.net, 2026-10-01: 919.500 / 500 kHz / SF10 / CR 4/5 / path hash mode 1 (2-byte). Replaces their original 902.25 guidance. TX power not published; 20 dBm matches the US entries." + }, + { + "id": "usa-oki-mesh-500khz-test", + "title": "USA - OKI-Mesh 500kHz Test", + "region": "United States", + "frequency": 909.75, + "bandwidth": 500, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power": 20, + "status": "published", + "notes": "docs.okimesh.org 500kHz-testing (test started 2026-09-23). OKI has not settled on a frequency; expect this to change. TX power not published; 20 dBm matches the US entries." + }, + { + "id": "russia-artyom-vvo", + "title": "Russia Artyom (VVO)", + "region": "Russia", + "frequency": 864.281, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-biysk-bsk", + "title": "Russia Biysk (BSK)", + "region": "Russia", + "frequency": 869.0, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 5, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-chelyabinsk-cek", + "title": "Russia Chelyabinsk (CEK)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-cherepovets-cee", + "title": "Russia Cherepovets (CEE)", + "region": "Russia", + "frequency": 868.57, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-irkutsk-ikt", + "title": "Russia Irkutsk (IKT)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-ivanovo-iwa", + "title": "Russia Ivanovo (IWA)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-izhevsk-ijk", + "title": "Russia Izhevsk (IJK)", + "region": "Russia", + "frequency": 868.732, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-kaluga-klf", + "title": "Russia Kaluga (KLF)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-kazan-kzn", + "title": "Russia Kazan (KZN)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-khabarovsk-khv", + "title": "Russia Khabarovsk (KHV)", + "region": "Russia", + "frequency": 864.281, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-kirov-kvx", + "title": "Russia Kirov (KVX)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-lipetsk-lpk", + "title": "Russia Lipetsk (LPK)", + "region": "Russia", + "frequency": 868.95, + "bandwidth": 62.5, + "spreading_factor": 9, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-moscow-mow", + "title": "Russia Moscow (MOW)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-nizhny-novgorod-goj", + "title": "Russia Nizhny Novgorod (GOJ)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-novosibirsk-ovb", + "title": "Russia Novosibirsk (OVB)", + "region": "Russia", + "frequency": 869.0, + "bandwidth": 62.5, + "spreading_factor": 9, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-rostov-on-don-rov", + "title": "Russia Rostov-on-Don (ROV)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 9, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-ryazan-rzn", + "title": "Russia Ryazan (RZN)", + "region": "Russia", + "frequency": 868.88, + "bandwidth": 62.5, + "spreading_factor": 9, + "coding_rate": 5, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-samara-kuf", + "title": "Russia Samara (KUF)", + "region": "Russia", + "frequency": 864.281, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-saratov-gsv", + "title": "Russia Saratov (GSV)", + "region": "Russia", + "frequency": 864.281, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-st-petersburg-led", + "title": "Russia St. Petersburg (LED)", + "region": "Russia", + "frequency": 868.856, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-tambov-tbw", + "title": "Russia Tambov (TBW)", + "region": "Russia", + "frequency": 868.95, + "bandwidth": 62.5, + "spreading_factor": 10, + "coding_rate": 5, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-tula-tya", + "title": "Russia Tula (TYA)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-tver-kld", + "title": "Russia Tver (KLD)", + "region": "Russia", + "frequency": 869.169, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-ufa-ufa", + "title": "Russia Ufa (UFA)", + "region": "Russia", + "frequency": 868.732, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 8, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-volgograd-vog", + "title": "Russia Volgograd (VOG)", + "region": "Russia", + "frequency": 869.525, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-voronezh-voz", + "title": "Russia Voronezh (VOZ)", + "region": "Russia", + "frequency": 868.731, + "bandwidth": 62.5, + "spreading_factor": 8, + "coding_rate": 6, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "russia-yekaterinburg-svx", + "title": "Russia Yekaterinburg (SVX)", + "region": "Russia", + "frequency": 869.046, + "bandwidth": 62.5, + "spreading_factor": 7, + "coding_rate": 7, + "tx_power": 20, + "status": "published", + "notes": "Carried over from zjs81/meshcore-open (Russian regional presets, 2026-05-13)." + }, + { + "id": "off-grid-433", + "title": "Off-Grid 433", + "region": "Off-Grid", + "frequency": 433.0, + "bandwidth": 250, + "spreading_factor": 11, + "coding_rate": 8, + "tx_power": 20, + "off_grid": true, + "status": "published", + "notes": "Client-repeat frequency, used by the companion's off-grid repeat mode." + }, + { + "id": "off-grid-869", + "title": "Off-Grid 869", + "region": "Off-Grid", + "frequency": 869.0, + "bandwidth": 250, + "spreading_factor": 11, + "coding_rate": 8, + "tx_power": 14, + "off_grid": true, + "status": "published", + "notes": "Client-repeat frequency, used by the companion's off-grid repeat mode." + }, + { + "id": "off-grid-918", + "title": "Off-Grid 918", + "region": "Off-Grid", + "frequency": 918.0, + "bandwidth": 250, + "spreading_factor": 11, + "coding_rate": 8, + "tx_power": 20, + "off_grid": true, + "status": "published", + "notes": "Client-repeat frequency, used by the companion's off-grid repeat mode." + } + ] +} diff --git a/scripts/validate_radio_presets.py b/scripts/validate_radio_presets.py new file mode 100644 index 0000000..a5e8062 --- /dev/null +++ b/scripts/validate_radio_presets.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Validate the radio preset files in radio-presets/. + + python scripts/validate_radio_presets.py # both files + python scripts/validate_radio_presets.py --overlay radio-presets/offband.json + python scripts/validate_radio_presets.py --upstream radio-presets/meshcore-upstream.json + +Exit 0 when every file checked is valid, 1 with one line per problem otherwise. +Standard library only, so CI needs no installs. The rules are in SCHEMA.md. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OVERLAY = ROOT / "radio-presets" / "offband.json" +UPSTREAM = ROOT / "radio-presets" / "meshcore-upstream.json" + +OVERLAY_FORMAT = "offband-radio-presets" +UPSTREAM_FORMAT = "meshcore-upstream-radio-presets" +FORMAT_VERSION = 1 + +# LoRa bandwidths, in kHz, that MeshCore radios accept. +BANDWIDTHS = (7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500) +ID_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + +OVERLAY_KEYS = {"id", "title", "region", "frequency", "bandwidth", "spreading_factor", "coding_rate", + "tx_power", "path_hash_size", "off_grid", "overrides_upstream", "status", "notes"} +OVERLAY_REQUIRED = {"id", "title", "region", "frequency", "bandwidth", "spreading_factor", + "coding_rate", "status"} +UPSTREAM_ENTRY_KEYS = {"title", "description", "frequency", "spreading_factor", "bandwidth", + "coding_rate", "network_settings"} +UPSTREAM_ENTRY_REQUIRED = {"title", "frequency", "spreading_factor", "bandwidth", "coding_rate"} + + +def _num(v): + """A JSON number, or a numeric string (the upstream API sends strings).""" + if isinstance(v, bool): + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + try: + return float(v) + except ValueError: + return None + return None + + +def _int(v): + n = _num(v) + return int(n) if n is not None and n == int(n) else None + + +def check_radio(where, freq, bw, sf, cr, problems): + f = _num(freq) + if f is None or not 150 <= f <= 2500: + problems.append(f"{where}: frequency {freq!r} must be a number of MHz, 150-2500") + b = _num(bw) + if b is None or not any(abs(b - x) < 1e-6 for x in BANDWIDTHS): + problems.append(f"{where}: bandwidth {bw!r} must be one of {', '.join(str(x) for x in BANDWIDTHS)} kHz") + s = _int(sf) + if s is None or not 5 <= s <= 12: + problems.append(f"{where}: spreading_factor {sf!r} must be a whole number 5-12") + c = _int(cr) + if c is None or not 5 <= c <= 8: + problems.append(f"{where}: coding_rate {cr!r} must be the 4/x denominator, a whole number 5-8") + + +def check_path_hash(where, v, problems): + h = _int(v) + if h is None or not 1 <= h <= 3: + problems.append(f"{where}: path_hash_size {v!r} must be a whole number of bytes, 1-3") + + +def validate_upstream(doc, problems): + """Checks the mirror file. Returns the set of upstream titles (for override checks).""" + titles = set() + if not isinstance(doc, dict): + problems.append("upstream: top level must be a JSON object") + return titles + if doc.get("format") != UPSTREAM_FORMAT: + problems.append(f"upstream: format must be {UPSTREAM_FORMAT!r}") + if doc.get("format_version") != FORMAT_VERSION: + problems.append(f"upstream: format_version must be {FORMAT_VERSION}") + for key in ("source", "credit", "updated_at"): + if not isinstance(doc.get(key), str) or not doc.get(key): + problems.append(f"upstream: {key} is required") + srs = doc.get("suggested_radio_settings") + if not isinstance(srs, dict): + problems.append("upstream: suggested_radio_settings must be an object") + return titles + entries = srs.get("entries") + if not isinstance(entries, list) or not entries: + problems.append("upstream: suggested_radio_settings.entries must be a non-empty list") + return titles + for i, e in enumerate(entries): + where = f"upstream entry {i}" + if not isinstance(e, dict): + problems.append(f"{where}: must be an object") + continue + where = f"upstream entry {i} ({e.get('title')!r})" + unknown = set(e) - UPSTREAM_ENTRY_KEYS + if unknown: + problems.append(f"{where}: unknown keys {sorted(unknown)} (the upstream shape changed)") + missing = UPSTREAM_ENTRY_REQUIRED - set(e) + if missing: + problems.append(f"{where}: missing {sorted(missing)}") + continue + if not isinstance(e["title"], str) or not e["title"].strip(): + problems.append(f"{where}: title must be a non-empty string") + elif e["title"] in titles: + problems.append(f"{where}: duplicate title") + else: + titles.add(e["title"]) + check_radio(where, e["frequency"], e["bandwidth"], e["spreading_factor"], e["coding_rate"], problems) + ns = e.get("network_settings") + if ns is not None: + if not isinstance(ns, dict) or set(ns) - {"path_hash_size"}: + problems.append(f"{where}: network_settings may only carry path_hash_size") + elif "path_hash_size" in ns: + check_path_hash(where, ns["path_hash_size"], problems) + return titles + + +def validate_overlay(doc, upstream_titles, problems): + if not isinstance(doc, dict): + problems.append("overlay: top level must be a JSON object") + return + if doc.get("format") != OVERLAY_FORMAT: + problems.append(f"overlay: format must be {OVERLAY_FORMAT!r}") + if doc.get("format_version") != FORMAT_VERSION: + problems.append(f"overlay: format_version must be {FORMAT_VERSION}") + presets = doc.get("presets") + if not isinstance(presets, list): + problems.append("overlay: presets must be a list") + return + ids, titles = set(), set() + for i, p in enumerate(presets): + where = f"overlay entry {i}" + if not isinstance(p, dict): + problems.append(f"{where}: must be an object") + continue + where = f"overlay entry {i} ({p.get('id')!r})" + unknown = set(p) - OVERLAY_KEYS + if unknown: + problems.append(f"{where}: unknown keys {sorted(unknown)}") + missing = OVERLAY_REQUIRED - set(p) + if missing: + problems.append(f"{where}: missing {sorted(missing)}") + continue + if not isinstance(p["id"], str) or not ID_RE.match(p["id"]): + problems.append(f"{where}: id must be lowercase letters, digits and single hyphens") + elif p["id"] in ids: + problems.append(f"{where}: duplicate id") + else: + ids.add(p["id"]) + title = p["title"] + if not isinstance(title, str) or not title.strip(): + problems.append(f"{where}: title must be a non-empty string") + elif title in titles: + problems.append(f"{where}: duplicate title") + else: + titles.add(title) + if not isinstance(p["region"], str) or not p["region"].strip(): + problems.append(f"{where}: region must be a non-empty string") + for key in ("frequency", "bandwidth", "spreading_factor", "coding_rate"): + if isinstance(p[key], str): + problems.append(f"{where}: {key} must be a JSON number, not a string") + check_radio(where, p["frequency"], p["bandwidth"], p["spreading_factor"], p["coding_rate"], problems) + if "tx_power" in p: + t = p["tx_power"] + if isinstance(t, bool) or not isinstance(t, int) or not -9 <= t <= 30: + problems.append(f"{where}: tx_power {t!r} must be a whole number of dBm, -9 to 30") + if "path_hash_size" in p: + check_path_hash(where, p["path_hash_size"], problems) + for key in ("off_grid", "overrides_upstream"): + if key in p and not isinstance(p[key], bool): + problems.append(f"{where}: {key} must be true or false") + if p["status"] not in ("published", "retired"): + problems.append(f"{where}: status must be 'published' or 'retired'") + if "notes" in p and not isinstance(p["notes"], str): + problems.append(f"{where}: notes must be a string") + if upstream_titles is not None and isinstance(title, str): + marked = p.get("overrides_upstream") is True + if title in upstream_titles and not marked: + problems.append(f"{where}: title {title!r} is also in the upstream list; " + "set overrides_upstream: true if replacing it is deliberate") + if marked and title not in upstream_titles: + problems.append(f"{where}: overrides_upstream is set but no upstream entry is titled {title!r}") + + +def load(path, problems): + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except FileNotFoundError: + problems.append(f"{path}: not found") + except ValueError as e: + problems.append(f"{path}: not valid JSON ({e})") + return None + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--overlay", help="overlay file to check (default: radio-presets/offband.json)") + ap.add_argument("--upstream", help="mirror file to check (default: radio-presets/meshcore-upstream.json)") + args = ap.parse_args(argv) + both = args.overlay is None and args.upstream is None + overlay_path = args.overlay or (OVERLAY if both else None) + upstream_path = args.upstream or (UPSTREAM if both or args.overlay else None) + + problems = [] + upstream_titles = None + if upstream_path: + doc = load(upstream_path, problems) + if doc is not None: + upstream_titles = validate_upstream(doc, problems) + if overlay_path: + doc = load(overlay_path, problems) + if doc is not None: + validate_overlay(doc, upstream_titles, problems) + + for p in problems: + print(f"INVALID: {p}") + if problems: + print(f"{len(problems)} problem(s).") + return 1 + checked = ", ".join(str(p) for p in (upstream_path, overlay_path) if p) + print(f"OK: {checked}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 4fd603472a885cb664b394682d9fc432289a7a9a Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:51:11 -0400 Subject: [PATCH 2/5] feat(OffbandMesh/meshcore-client#723): mirror MeshCore's radio presets with a scheduled workflow scripts/mirror_meshcore_presets.py fetches the preset list Liam Cottle maintains for MeshCore (api.meshcore.nz/api/v1/config), validates it, and rewrites radio-presets/meshcore-upstream.json only when the presets changed. A fetch failure or a shape change exits 1 and writes nothing. .github/workflows/radio-presets.yml runs it daily and on demand, committing directly to main, and validates both preset files on every push and PR that touches them. An upstream title that clashes with the overlay still updates the mirror, then fails the run loudly. Initial mirror: 26 entries, fetched 2026-10-01. Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/radio-presets.yml | 61 +++++++ radio-presets/meshcore-upstream.json | 244 +++++++++++++++++++++++++++ scripts/mirror_meshcore_presets.py | 93 ++++++++++ 3 files changed, 398 insertions(+) create mode 100644 .github/workflows/radio-presets.yml create mode 100644 radio-presets/meshcore-upstream.json create mode 100644 scripts/mirror_meshcore_presets.py diff --git a/.github/workflows/radio-presets.yml b/.github/workflows/radio-presets.yml new file mode 100644 index 0000000..564ab48 --- /dev/null +++ b/.github/workflows/radio-presets.yml @@ -0,0 +1,61 @@ +# Radio presets: validate on every change, and mirror MeshCore's upstream list daily. +# +# The mirror job fetches https://api.meshcore.nz/api/v1/config (MeshCore's preset list, +# maintained by Liam Cottle), validates it, and commits radio-presets/meshcore-upstream.json +# straight to main only when the presets changed. A fetch failure or a shape change fails +# the run and commits nothing, so the last good mirror stays in place. +name: Radio presets + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + push: + branches: [main] + paths: ["radio-presets/**", "scripts/*radio_presets*.py", "scripts/mirror_meshcore_presets.py", ".github/workflows/radio-presets.yml"] + pull_request: + paths: ["radio-presets/**", "scripts/*radio_presets*.py", "scripts/mirror_meshcore_presets.py", ".github/workflows/radio-presets.yml"] + +permissions: + contents: read + +concurrency: + group: radio-presets-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + if: github.event_name == 'push' || github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate both preset files + run: python3 scripts/validate_radio_presets.py + + mirror: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + - name: Fetch and validate the upstream list + run: python3 scripts/mirror_meshcore_presets.py + - name: Commit the mirror if it changed + run: | + git add radio-presets/meshcore-upstream.json + if git diff --cached --quiet; then + echo "Mirror unchanged; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: mirror MeshCore radio presets from api.meshcore.nz" + git push origin HEAD:main + # Runs after the commit on purpose: if the new upstream list clashes with an + # overlay title, the mirror still updates for everyone and this step fails the + # run, so the clash gets fixed in offband.json (mark it overrides_upstream, or retire ours). + - name: Validate the overlay against the new mirror + run: python3 scripts/validate_radio_presets.py diff --git a/radio-presets/meshcore-upstream.json b/radio-presets/meshcore-upstream.json new file mode 100644 index 0000000..1ca9844 --- /dev/null +++ b/radio-presets/meshcore-upstream.json @@ -0,0 +1,244 @@ +{ + "format": "meshcore-upstream-radio-presets", + "format_version": 1, + "source": "https://api.meshcore.nz/api/v1/config", + "credit": "Radio presets maintained by Liam Cottle for MeshCore (meshcore.nz), the list the stock MeshCore app shows. Mirrored unmodified from https://api.meshcore.nz/api/v1/config. Offband's own additions live in offband.json.", + "updated_at": "2026-10-01T07:48:36Z", + "suggested_radio_settings": { + "info_message": "These presets are suggested by the community.", + "entries": [ + { + "title": "Australia", + "description": "915.800MHz / SF10 / BW250 / CR5", + "frequency": "915.800", + "spreading_factor": "10", + "bandwidth": "250", + "coding_rate": "5" + }, + { + "title": "Australia (Narrow)", + "description": "916.575MHz / SF7 / BW62.5 / CR7", + "frequency": "916.575", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "7" + }, + { + "title": "Australia (Mid)", + "description": "915.075MHz / SF9 / BW125 / CR5", + "frequency": "915.075", + "spreading_factor": "9", + "bandwidth": "125", + "coding_rate": "5" + }, + { + "title": "Australia: SA, WA", + "description": "923.125MHz / SF8 / BW62.5 / CR8", + "frequency": "923.125", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "Australia: QLD", + "description": "923.125MHz / SF8 / BW62.5 / CR5", + "frequency": "923.125", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "5" + }, + { + "title": "Brazil", + "description": "923.125MHz / SF8 / BW62.5 / CR8", + "frequency": "923.125", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "Canada", + "description": "910.525MHz / SF7 / BW62.5 / CR5 / 3B", + "frequency": "910.525", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 3 + } + }, + { + "title": "Costa Rica", + "description": "910.525MHz / SF11 / BW125 / CR5", + "frequency": "910.525", + "spreading_factor": "11", + "bandwidth": "125", + "coding_rate": "5" + }, + { + "title": "EU/UK (Narrow)", + "description": "869.618MHz / SF8 / BW62.5 / CR8", + "frequency": "869.618", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "EU/UK (Deprecated)", + "description": "869.525MHz / SF11 / BW250 / CR5", + "frequency": "869.525", + "spreading_factor": "11", + "bandwidth": "250", + "coding_rate": "5" + }, + { + "title": "Czech Republic (Narrow)", + "description": "869.432MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "869.432", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "EU 433MHz (Long Range)", + "description": "433.650MHz / SF11 / BW250 / CR5", + "frequency": "433.650", + "spreading_factor": "11", + "bandwidth": "250", + "coding_rate": "5" + }, + { + "title": "EU 433MHz (Narrow)", + "description": "433.650MHz / SF8 / BW62.5 / CR8", + "frequency": "433.650", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "Hungary", + "description": "869.618MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "Netherlands", + "description": "869.618MHz / SF7 / BW62.5 / CR5", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5" + }, + { + "title": "Netherlands (Limburg)", + "description": "869.618MHz / SF8 / BW62.5 / CR8 / 2B", + "frequency": "869.618", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "New Zealand (Narrow)", + "description": "917.375MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "917.375", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "New Zealand (Gisborne)", + "description": "917.375MHz / SF11 / BW250 / CR5 / 1B", + "frequency": "917.375", + "spreading_factor": "11", + "bandwidth": "250", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 1 + } + }, + { + "title": "Portugal 433", + "description": "433.375MHz / SF9 / BW62.5 / CR6", + "frequency": "433.375", + "spreading_factor": "9", + "bandwidth": "62.5", + "coding_rate": "6" + }, + { + "title": "Portugal 868", + "description": "869.618MHz / SF7 / BW62.5 / CR6", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "6" + }, + { + "title": "Slovakia", + "description": "869.618MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "Switzerland", + "description": "869.618MHz / SF8 / BW62.5 / CR8", + "frequency": "869.618", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "USA", + "description": "910.525MHz / SF7 / BW62.5 / CR5", + "frequency": "910.525", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5" + }, + { + "title": "USA - Southern California", + "description": "927.875MHz / SF7 / BW62.5 / CR5 / 3B", + "frequency": "927.875", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 3 + } + }, + { + "title": "Vietnam (Narrow)", + "description": "920.250MHz / SF8 / BW62.5 / CR5", + "frequency": "920.250", + "spreading_factor": "8", + "bandwidth": "62.5", + "coding_rate": "5" + }, + { + "title": "Vietnam (Deprecated)", + "description": "920.250MHz / SF11 / BW250 / CR5", + "frequency": "920.250", + "spreading_factor": "11", + "bandwidth": "250", + "coding_rate": "5" + } + ] + } +} diff --git a/scripts/mirror_meshcore_presets.py b/scripts/mirror_meshcore_presets.py new file mode 100644 index 0000000..aa838b0 --- /dev/null +++ b/scripts/mirror_meshcore_presets.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Mirror MeshCore's suggested radio presets into radio-presets/meshcore-upstream.json. + + python scripts/mirror_meshcore_presets.py # fetch from the API + python scripts/mirror_meshcore_presets.py --from-file x # use a saved response (tests) + +The source is the public preset list maintained by Liam Cottle for MeshCore, the +same list the stock MeshCore app shows. It is copied unmodified, with a header that +credits the source. + +Exit codes: + 0 mirror unchanged, or updated (prints which) + 1 fetch failed, the response isn't valid, or the presets fail validation. + Nothing is written, so the last good mirror stays in place. +Standard library only. +""" +import argparse +import json +import sys +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import validate_radio_presets as v # noqa: E402 + +SOURCE = "https://api.meshcore.nz/api/v1/config" +CREDIT = ("Radio presets maintained by Liam Cottle for MeshCore (meshcore.nz), the list " + "the stock MeshCore app shows. Mirrored unmodified from " + SOURCE + ". " + "Offband's own additions live in offband.json.") +USER_AGENT = "offband-config-profiles-mirror (+https://github.com/OffbandMesh/config-profiles)" + + +def fetch(url): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as resp: + if resp.status != 200: + raise RuntimeError(f"HTTP {resp.status}") + return resp.read().decode("utf-8") + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--from-file", help="read the API response from a file instead of fetching") + ap.add_argument("--out", default=str(v.UPSTREAM), help="mirror file to write") + args = ap.parse_args(argv) + + try: + raw = Path(args.from_file).read_text(encoding="utf-8") if args.from_file else fetch(SOURCE) + api = json.loads(raw) + except Exception as e: # any fetch or parse failure fails the run loudly + print(f"FAILED: could not read the preset list from {args.from_file or SOURCE}: {e}") + return 1 + + srs = api.get("config", {}).get("suggested_radio_settings") if isinstance(api, dict) else None + if not isinstance(srs, dict): + print("FAILED: the response has no config.suggested_radio_settings object (the upstream shape changed)") + return 1 + + out = Path(args.out) + old = None + if out.exists(): + try: + old = json.loads(out.read_text(encoding="utf-8")) + except ValueError: + old = None + if isinstance(old, dict) and old.get("suggested_radio_settings") == srs: + print(f"UNCHANGED: {len(srs.get('entries') or [])} presets, nothing to write") + return 0 + + doc = { + "format": v.UPSTREAM_FORMAT, + "format_version": v.FORMAT_VERSION, + "source": SOURCE, + "credit": CREDIT, + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "suggested_radio_settings": srs, + } + problems = [] + v.validate_upstream(doc, problems) + if problems: + for p in problems: + print(f"INVALID: {p}") + print(f"FAILED: {len(problems)} problem(s); the mirror was not updated") + return 1 + + out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"UPDATED: {len(srs['entries'])} presets written to {out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 578f0b0b4940910eed61074efd58f4d784c734a9 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:52:34 -0400 Subject: [PATCH 3/5] docs(OffbandMesh/meshcore-client#724): credit Liam Cottle / MeshCore for the upstream presets README gains a Radio presets section (the two files, precedence, how to edit) and a Credit section naming Liam Cottle and MeshCore as the source of the mirrored list. The mirror's credit header now links to it. Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- README.md | 29 ++++++++++++++++++++++++++++ radio-presets/meshcore-upstream.json | 2 +- scripts/mirror_meshcore_presets.py | 3 ++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec501c2..2173e15 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,35 @@ raw.githubusercontent, S3, your own box). Give users the directory URL (or the [`profiles.json.example`](profiles.json.example) as a starting point. A region that would rather not depend on Offband can run entirely on its own catalog. +## Radio presets + +The app's regional radio presets (frequency, bandwidth, spreading factor, coding +rate) live in [`radio-presets/`](radio-presets/), not in the app's code, so they can +change without an app release. The app ships with a copy and refreshes from here. + +- [`meshcore-upstream.json`](radio-presets/meshcore-upstream.json): MeshCore's + suggested presets, the same list the stock MeshCore app shows. Mirrored daily and + unmodified by the [Radio presets](.github/workflows/radio-presets.yml) workflow. + Don't edit it by hand. +- [`offband.json`](radio-presets/offband.json): Offband's additions, for meshes the + upstream list doesn't cover yet (OKI-Mesh, Philly Mesh, Arizona, Russian cities, + Off-Grid). Where a title matches an upstream entry, ours wins. + +To add or change a preset, edit `offband.json`, run +`python scripts/validate_radio_presets.py`, and open a PR. Field rules are in +[SCHEMA.md](SCHEMA.md#radio-presets-radio-presets). + +### Credit + +The upstream presets are maintained by **Liam Cottle** for +[MeshCore](https://github.com/meshcore-dev/MeshCore) and published at +`https://api.meshcore.nz/api/v1/config`, where MeshCore apps and tools read them. +They are community-suggested values; regional updates go to Liam through the +MeshCore Discord (see the [MeshCore FAQ](https://docs.meshcore.io/faq/)). Offband +mirrors them unchanged, with thanks. + ## License MIT — see [LICENSE](LICENSE). Profiles you contribute are shared under the same. +The mirrored MeshCore presets in `radio-presets/meshcore-upstream.json` are +community-suggested radio parameters, credited above. diff --git a/radio-presets/meshcore-upstream.json b/radio-presets/meshcore-upstream.json index 1ca9844..f8dfb7e 100644 --- a/radio-presets/meshcore-upstream.json +++ b/radio-presets/meshcore-upstream.json @@ -2,7 +2,7 @@ "format": "meshcore-upstream-radio-presets", "format_version": 1, "source": "https://api.meshcore.nz/api/v1/config", - "credit": "Radio presets maintained by Liam Cottle for MeshCore (meshcore.nz), the list the stock MeshCore app shows. Mirrored unmodified from https://api.meshcore.nz/api/v1/config. Offband's own additions live in offband.json.", + "credit": "Radio presets maintained by Liam Cottle for MeshCore (meshcore.nz), the list the stock MeshCore app shows. Mirrored unmodified from https://api.meshcore.nz/api/v1/config. Offband's own additions live in offband.json. Credit: https://github.com/OffbandMesh/config-profiles#credit", "updated_at": "2026-10-01T07:48:36Z", "suggested_radio_settings": { "info_message": "These presets are suggested by the community.", diff --git a/scripts/mirror_meshcore_presets.py b/scripts/mirror_meshcore_presets.py index aa838b0..ad15c2b 100644 --- a/scripts/mirror_meshcore_presets.py +++ b/scripts/mirror_meshcore_presets.py @@ -27,7 +27,8 @@ SOURCE = "https://api.meshcore.nz/api/v1/config" CREDIT = ("Radio presets maintained by Liam Cottle for MeshCore (meshcore.nz), the list " "the stock MeshCore app shows. Mirrored unmodified from " + SOURCE + ". " - "Offband's own additions live in offband.json.") + "Offband's own additions live in offband.json. " + "Credit: https://github.com/OffbandMesh/config-profiles#credit") USER_AGENT = "offband-config-profiles-mirror (+https://github.com/OffbandMesh/config-profiles)" From 2f10fff335639209bbcd6a01a47991f26f9ca2b4 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:01:06 -0400 Subject: [PATCH 4/5] fix(OffbandMesh/meshcore-client#725): apply pre-PR review, validate before the mirror commits The scheduled job now validates the overlay against the freshly fetched mirror before committing, so main always passes its own validation; a title clash fails the run and commits nothing. path_hash_size in the overlay must be a JSON number, like every other overlay field. Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- .github/workflows/radio-presets.yml | 28 ++++++++++++++++++---------- scripts/validate_radio_presets.py | 6 +++++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.github/workflows/radio-presets.yml b/.github/workflows/radio-presets.yml index 564ab48..f08c469 100644 --- a/.github/workflows/radio-presets.yml +++ b/.github/workflows/radio-presets.yml @@ -1,9 +1,10 @@ # Radio presets: validate on every change, and mirror MeshCore's upstream list daily. # # The mirror job fetches https://api.meshcore.nz/api/v1/config (MeshCore's preset list, -# maintained by Liam Cottle), validates it, and commits radio-presets/meshcore-upstream.json -# straight to main only when the presets changed. A fetch failure or a shape change fails -# the run and commits nothing, so the last good mirror stays in place. +# maintained by Liam Cottle), validates it together with the overlay, and commits +# radio-presets/meshcore-upstream.json straight to main only when the presets changed. A +# fetch failure, a shape change or a title clash fails the run and commits nothing, so +# the last good mirror stays in place. name: Radio presets on: @@ -43,19 +44,26 @@ jobs: ref: main - name: Fetch and validate the upstream list run: python3 scripts/mirror_meshcore_presets.py - - name: Commit the mirror if it changed + - name: Check for changes + id: diff run: | git add radio-presets/meshcore-upstream.json if git diff --cached --quiet; then echo "Mirror unchanged; nothing to commit." - exit 0 + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" fi + # Before the commit, so main always passes its own validation. If a new upstream + # title clashes with one in offband.json, the run fails and nothing is committed; + # fix it in offband.json (set overrides_upstream, or retire ours) and rerun. + - name: Validate the overlay against the new mirror + if: steps.diff.outputs.changed == 'true' + run: python3 scripts/validate_radio_presets.py + - name: Commit the mirror + if: steps.diff.outputs.changed == 'true' + run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "chore: mirror MeshCore radio presets from api.meshcore.nz" git push origin HEAD:main - # Runs after the commit on purpose: if the new upstream list clashes with an - # overlay title, the mirror still updates for everyone and this step fails the - # run, so the clash gets fixed in offband.json (mark it overrides_upstream, or retire ours). - - name: Validate the overlay against the new mirror - run: python3 scripts/validate_radio_presets.py diff --git a/scripts/validate_radio_presets.py b/scripts/validate_radio_presets.py index a5e8062..62ef9c6 100644 --- a/scripts/validate_radio_presets.py +++ b/scripts/validate_radio_presets.py @@ -175,7 +175,11 @@ def validate_overlay(doc, upstream_titles, problems): if isinstance(t, bool) or not isinstance(t, int) or not -9 <= t <= 30: problems.append(f"{where}: tx_power {t!r} must be a whole number of dBm, -9 to 30") if "path_hash_size" in p: - check_path_hash(where, p["path_hash_size"], problems) + h = p["path_hash_size"] + if isinstance(h, bool) or not isinstance(h, int): + problems.append(f"{where}: path_hash_size must be a JSON number, not {type(h).__name__}") + else: + check_path_hash(where, h, problems) for key in ("off_grid", "overrides_upstream"): if key in p and not isinstance(p[key], bool): problems.append(f"{where}: {key} must be true or false") From 857d4e95fc55ac58aaf298286eaedbaeab635980 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:09:15 -0400 Subject: [PATCH 5/5] fix(OffbandMesh/meshcore-client#721): use region "USA" so overlay and upstream group together Upstream US titles start with "USA" ("USA", "USA - Southern California"); the overlay's "United States" would have split the picker into two US groups. Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- SCHEMA.md | 4 ++-- radio-presets/offband.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SCHEMA.md b/SCHEMA.md index c4017cd..f92d095 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -125,7 +125,7 @@ python scripts/validate_radio_presets.py { "id": "usa-philly-mesh", "title": "USA - Philly Mesh", - "region": "United States", + "region": "USA", "frequency": 919.5, "bandwidth": 500, "spreading_factor": 10, @@ -143,7 +143,7 @@ python scripts/validate_radio_presets.py |---|---|---| | `id` | yes | Stable key: lowercase letters, digits, single hyphens. Never reuse or change it | | `title` | yes | Shown in the picker; unique in this file | -| `region` | yes | Picker group, e.g. `United States`, `Russia`, `Off-Grid` | +| `region` | yes | Picker group, e.g. `USA`, `Russia`, `Off-Grid`. Use the same word the upstream titles start with, so both lists land in one group | | `frequency` | yes | MHz, a JSON number, 150 to 2500 | | `bandwidth` | yes | kHz, one of 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 | | `spreading_factor` | yes | 5 to 12 | diff --git a/radio-presets/offband.json b/radio-presets/offband.json index c8b4c2f..3399b4a 100644 --- a/radio-presets/offband.json +++ b/radio-presets/offband.json @@ -6,7 +6,7 @@ { "id": "usa-arizona", "title": "USA - Arizona", - "region": "United States", + "region": "USA", "frequency": 908.205, "bandwidth": 62.5, "spreading_factor": 9, @@ -18,7 +18,7 @@ { "id": "usa-philly-mesh", "title": "USA - Philly Mesh", - "region": "United States", + "region": "USA", "frequency": 919.5, "bandwidth": 500, "spreading_factor": 10, @@ -31,7 +31,7 @@ { "id": "usa-oki-mesh-500khz-test", "title": "USA - OKI-Mesh 500kHz Test", - "region": "United States", + "region": "USA", "frequency": 909.75, "bandwidth": 500, "spreading_factor": 10,