diff --git a/.github/workflows/radio-presets.yml b/.github/workflows/radio-presets.yml
new file mode 100644
index 0000000..f08c469
--- /dev/null
+++ b/.github/workflows/radio-presets.yml
@@ -0,0 +1,69 @@
+# 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 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:
+ 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: 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."
+ 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
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/SCHEMA.md b/SCHEMA.md
index 85a2308..f92d095 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": "USA",
+ "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. `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 |
+| `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/meshcore-upstream.json b/radio-presets/meshcore-upstream.json
new file mode 100644
index 0000000..f8dfb7e
--- /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. 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.",
+ "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/radio-presets/offband.json b/radio-presets/offband.json
new file mode 100644
index 0000000..3399b4a
--- /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": "USA",
+ "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": "USA",
+ "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": "USA",
+ "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/mirror_meshcore_presets.py b/scripts/mirror_meshcore_presets.py
new file mode 100644
index 0000000..ad15c2b
--- /dev/null
+++ b/scripts/mirror_meshcore_presets.py
@@ -0,0 +1,94 @@
+#!/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. "
+ "Credit: https://github.com/OffbandMesh/config-profiles#credit")
+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())
diff --git a/scripts/validate_radio_presets.py b/scripts/validate_radio_presets.py
new file mode 100644
index 0000000..62ef9c6
--- /dev/null
+++ b/scripts/validate_radio_presets.py
@@ -0,0 +1,240 @@
+#!/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:
+ 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")
+ 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())