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