diff --git a/assets/radio_presets/meshcore-upstream.json b/assets/radio_presets/meshcore-upstream.json new file mode 100644 index 00000000..f8dfb7e1 --- /dev/null +++ b/assets/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/assets/radio_presets/offband.json b/assets/radio_presets/offband.json new file mode 100644 index 00000000..3399b4aa --- /dev/null +++ b/assets/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/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 79ea546d..abadb3b6 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1203,7 +1203,8 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) { // Build CMD_SET_RADIO_PARAMS frame // Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9) // [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+) -// freq: frequency in Hz (300000-2500000) +// freq: frequency in kHz despite the parameter name (stock firmware stores +// freq / 1000.0 as MHz and accepts 150000-2500000) // bw: bandwidth in Hz (7000-500000) // sf: spreading factor (5-12) // cr: coding rate (5-8) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7ffd3f5..ae9d4130 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -274,6 +274,7 @@ "settings_aboutDonate": "Donate or sponsor", "settings_aboutLinkFailed": "Could not open the link", "settings_aboutOpenMeteoAttribution": "LOS elevation data: Open-Meteo (CC BY 4.0)", + "settings_aboutPresetsAttribution": "Regional radio presets: MeshCore's suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.", "settings_infoName": "Name", "settings_infoId": "ID", "settings_infoStatus": "Status", @@ -289,6 +290,20 @@ "settings_infoContactsCount": "Contacts Count", "settings_infoChannelCount": "Channel Count", "settings_presets": "Presets", + "settings_presetSourceMeshCore": "MeshCore", + "settings_presetSourceOffband": "Offband", + "settings_presetsRefresh": "Update presets", + "settings_presetsUpdating": "Updating presets...", + "settings_presetsUpdateFailed": "Couldn't update presets. Showing the saved list.", + "settings_presetsUpdatedAt": "Presets updated {date}", + "@settings_presetsUpdatedAt": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "settings_presetsBundled": "Presets included with this version of the app", "settings_frequency": "Frequency (MHz)", "settings_frequencyHelper": "300.0 - 2500.0", "settings_frequencyInvalid": "Invalid frequency (300-2500 MHz)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4911110e..57001df4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1270,6 +1270,12 @@ abstract class AppLocalizations { /// **'LOS elevation data: Open-Meteo (CC BY 4.0)'** String get settings_aboutOpenMeteoAttribution; + /// No description provided for @settings_aboutPresetsAttribution. + /// + /// In en, this message translates to: + /// **'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'** + String get settings_aboutPresetsAttribution; + /// No description provided for @settings_infoName. /// /// In en, this message translates to: @@ -1360,6 +1366,48 @@ abstract class AppLocalizations { /// **'Presets'** String get settings_presets; + /// No description provided for @settings_presetSourceMeshCore. + /// + /// In en, this message translates to: + /// **'MeshCore'** + String get settings_presetSourceMeshCore; + + /// No description provided for @settings_presetSourceOffband. + /// + /// In en, this message translates to: + /// **'Offband'** + String get settings_presetSourceOffband; + + /// No description provided for @settings_presetsRefresh. + /// + /// In en, this message translates to: + /// **'Update presets'** + String get settings_presetsRefresh; + + /// No description provided for @settings_presetsUpdating. + /// + /// In en, this message translates to: + /// **'Updating presets...'** + String get settings_presetsUpdating; + + /// No description provided for @settings_presetsUpdateFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t update presets. Showing the saved list.'** + String get settings_presetsUpdateFailed; + + /// No description provided for @settings_presetsUpdatedAt. + /// + /// In en, this message translates to: + /// **'Presets updated {date}'** + String settings_presetsUpdatedAt(String date); + + /// No description provided for @settings_presetsBundled. + /// + /// In en, this message translates to: + /// **'Presets included with this version of the app'** + String get settings_presetsBundled; + /// No description provided for @settings_frequency. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 5feced06..784e4a3e 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -651,6 +651,10 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Име'; @@ -697,6 +701,31 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_presets => 'Предварителни настройки'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Честота (MHz)'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a7352c91..5b4d83e3 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -646,6 +646,10 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS-Höhendaten: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Name'; @@ -692,6 +696,31 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_presets => 'Voreinstellungen'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frequenz (MHz)'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 06f817f9..d52671e9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -637,6 +637,10 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS elevation data: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Name'; @@ -683,6 +687,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_presets => 'Presets'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frequency (MHz)'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0ddde1c3..a39262f2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -647,6 +647,10 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Datos de elevación LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Nombre'; @@ -693,6 +697,31 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_presets => 'Preajustes'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frecuencia (MHz)'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f47297a5..40d259e3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -651,6 +651,10 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Données d\'élévation LOS : Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Nom'; @@ -697,6 +701,31 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_presets => 'Préréglages'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Fréquence (MHz)'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 0d474ab6..62dc23ae 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -649,6 +649,10 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS magassági adatok: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Név'; @@ -695,6 +699,31 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_presets => 'Előre beállított beállítások'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frekvencia (MHz)'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 168bc3cd..9953f25d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -649,6 +649,10 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Dati di elevazione LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Nome'; @@ -695,6 +699,31 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_presets => 'Preset'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frequenza (MHz)'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 7362134c..04572b59 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -622,6 +622,10 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS 標高データ:Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => '名前'; @@ -668,6 +672,31 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_presets => 'プリセット'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => '周波数 (MHz)'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 12246421..30753a94 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -622,6 +622,10 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS 고도 데이터: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => '이름'; @@ -668,6 +672,31 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_presets => '기본 설정'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => '주파수 (MHz)'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5669cb0b..c8007b6b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -643,6 +643,10 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS-hoogtegegevens: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Naam'; @@ -689,6 +693,31 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_presets => 'Voorgeprogrammeerde instellingen'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frequentie (MHz)'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 150ec4da..f744df37 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -651,6 +651,10 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Nazwa'; @@ -697,6 +701,31 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_presets => 'Presety'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Częstotliwość (MHz)'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ba3fde9e..6d8b8db1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -649,6 +649,10 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Dados de elevação LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Nome'; @@ -695,6 +699,31 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_presets => 'Configurações pré-definidas'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frequência (MHz)'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 34b32733..d851b30a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -648,6 +648,10 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Данные о высоте LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Имя'; @@ -694,6 +698,31 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_presets => 'Пресеты'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Частота (МГц)'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 8083780d..6c4b847c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -643,6 +643,10 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Meno'; @@ -689,6 +693,31 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_presets => 'Prednastavenia'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frekvencia (MHz)'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index c275f408..11c4fe94 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -640,6 +640,10 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Podatki o višini LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Ime'; @@ -686,6 +690,31 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_presets => 'Prednastavitve'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frekvenca (MHz)'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index ee9e70ac..a1a7b3b8 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -638,6 +638,10 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS-höjddata: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Namn'; @@ -684,6 +688,31 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_presets => 'Fördefinierade inställningar'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Frekvens (MHz)'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 78b2463d..f51352f8 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -646,6 +646,10 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'Дані про висоту LOS: Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => 'Ім\'я'; @@ -692,6 +696,31 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_presets => 'Попередні налаштування'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => 'Частота (МГц)'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 736edea9..485b30ee 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -614,6 +614,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_aboutOpenMeteoAttribution => 'LOS 高程数据:Open-Meteo (CC BY 4.0)'; + @override + String get settings_aboutPresetsAttribution => + 'Regional radio presets: MeshCore\'s suggested settings, maintained by Liam Cottle (api.meshcore.nz), with Offband additions.'; + @override String get settings_infoName => '名称'; @@ -660,6 +664,31 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_presets => '预设'; + @override + String get settings_presetSourceMeshCore => 'MeshCore'; + + @override + String get settings_presetSourceOffband => 'Offband'; + + @override + String get settings_presetsRefresh => 'Update presets'; + + @override + String get settings_presetsUpdating => 'Updating presets...'; + + @override + String get settings_presetsUpdateFailed => + 'Couldn\'t update presets. Showing the saved list.'; + + @override + String settings_presetsUpdatedAt(String date) { + return 'Presets updated $date'; + } + + @override + String get settings_presetsBundled => + 'Presets included with this version of the app'; + @override String get settings_frequency => '频率 (MHz)'; diff --git a/lib/main.dart b/lib/main.dart index c9e76ac4..dc893b8e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'services/block_service.dart'; import 'services/window_geometry_service.dart'; import 'services/store_consolidation_service.dart'; import 'services/storage_health_service.dart'; +import 'services/radio_preset_service.dart'; import 'storage/drift/blob_store.dart'; import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; @@ -118,6 +119,11 @@ void main() async { await timeoutPredictionService.initialize(); await blockService.load(); + // Regional radio presets (#728): the last good copy, or the one bundled with + // the app. The picker refreshes it from config-profiles when opened. + final radioPresetService = RadioPresetService(); + await radioPresetService.load(); + // Wire up connector with services connector.initialize( retryService: retryService, @@ -157,6 +163,7 @@ void main() async { uiViewStateService: uiViewStateService, timeoutPredictionService: timeoutPredictionService, blockService: blockService, + radioPresetService: radioPresetService, ), ); } @@ -177,6 +184,18 @@ https://open-meteo.com/en/docs/elevation-api Attribution license reference: Creative Commons Attribution 4.0 International (CC BY 4.0) https://creativecommons.org/licenses/by/4.0/ +''', + ); + yield const LicenseEntryWithLineBreaks( + ['MeshCore suggested radio presets'], + ''' +Regional radio presets are MeshCore's suggested settings, maintained by Liam +Cottle for MeshCore and published at https://api.meshcore.nz/api/v1/config. +Offband mirrors them unmodified in OffbandMesh/config-profiles and adds its +own regional presets alongside. + +MeshCore: https://github.com/meshcore-dev/MeshCore +Mirror and credit: https://github.com/OffbandMesh/config-profiles#credit ''', ); }); @@ -198,6 +217,7 @@ class MeshCoreApp extends StatelessWidget { final UiViewStateService uiViewStateService; final TimeoutPredictionService timeoutPredictionService; final BlockService blockService; + final RadioPresetService radioPresetService; const MeshCoreApp({ super.key, @@ -216,6 +236,7 @@ class MeshCoreApp extends StatelessWidget { required this.uiViewStateService, required this.timeoutPredictionService, required this.blockService, + required this.radioPresetService, }); @override @@ -237,6 +258,7 @@ class MeshCoreApp extends StatelessWidget { Provider.value(value: mapTileCacheService), ChangeNotifierProvider.value(value: timeoutPredictionService), ChangeNotifierProvider.value(value: blockService), + ChangeNotifierProvider.value(value: radioPresetService), ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)), ], child: Consumer( diff --git a/lib/models/radio_preset.dart b/lib/models/radio_preset.dart new file mode 100644 index 00000000..b04cf33a --- /dev/null +++ b/lib/models/radio_preset.dart @@ -0,0 +1,294 @@ +import 'dart:convert'; + +import 'radio_settings.dart'; + +// Regional radio presets (#647). Two sources, both published in the +// OffbandMesh/config-profiles repo under radio-presets/ (schema in its SCHEMA.md): +// - meshcore-upstream.json: MeshCore's suggested presets (maintained by Liam +// Cottle), mirrored unmodified; numbers arrive as strings. +// - offband.json: Offband's overlay; numbers are JSON numbers. +// Parsing is resilient: a malformed entry is skipped and counted, never fatal, +// but a file of the wrong format or a newer format_version is rejected. + +const String kUpstreamPresetFormat = 'meshcore-upstream-radio-presets'; +const String kOverlayPresetFormat = 'offband-radio-presets'; + +/// Highest format_version this build understands, for both files. +const int kRadioPresetFormatVersion = 1; + +enum RadioPresetSource { meshcore, offband } + +class RadioPreset { + const RadioPreset({ + required this.id, + required this.title, + required this.region, + required this.frequencyMHz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + required this.source, + this.txPowerDbm, + this.pathHashBytes, + this.offGrid = false, + }); + + final String id; + final String title; + + /// Picker group, e.g. "USA", "Australia", "Off-Grid". + final String region; + final double frequencyMHz; + final LoRaBandwidth bandwidth; + final LoRaSpreadingFactor spreadingFactor; + final LoRaCodingRate codingRate; + final RadioPresetSource source; + + /// Null leaves the radio's TX power alone (upstream presets never set it). + final int? txPowerDbm; + + /// Path hash size in bytes (1-3), as published. Null leaves the radio's + /// setting alone. + final int? pathHashBytes; + + /// A client-repeat (off-grid) frequency rather than a regional preset. + final bool offGrid; + + /// Frequency in kHz, the unit CMD_SET_RADIO_PARAMS carries. + int get frequencyKHz => (frequencyMHz * 1000).round(); + + /// The firmware's path hash mode for [pathHashBytes], or null to leave it. + /// + /// Presets publish the size in bytes; firmware takes a zero-based mode + /// (`sendFlood(..., path_hash_mode + 1)`): 1 byte = mode 0, 2 bytes = mode 1, + /// 3 bytes = mode 2. Passing a published 2 straight through would select + /// 3-byte hashes and put the node off its mesh. + int? get pathHashMode => pathHashModeForBytes(pathHashBytes); +} + +/// Whether a radio using [radioPathHashBytes] fits [preset]'s path hash +/// (#747). Presets without a path hash fit any radio; one with a size only +/// fits a radio on that size. Without this, presets that differ only by path +/// hash (MeshCore's "Canada" 3-byte and "USA") are indistinguishable. +bool presetPathHashMatches(RadioPreset preset, int radioPathHashBytes) => + preset.pathHashBytes == null || preset.pathHashBytes == radioPathHashBytes; + +/// Zero-based firmware path hash mode for a published size in bytes (1-3). +/// Null for null or out-of-range input, so nothing is sent. +int? pathHashModeForBytes(int? bytes) { + if (bytes == null || bytes < 1 || bytes > 3) return null; + return bytes - 1; +} + +class RadioPresetFormatException implements Exception { + const RadioPresetFormatException(this.message); + final String message; + @override + String toString() => 'RadioPresetFormatException: $message'; +} + +class RadioPresetParseResult { + const RadioPresetParseResult(this.presets, this.skipped); + final List presets; + + /// Malformed entries left out, so a partly bad file never looks complete. + final int skipped; +} + +/// Parse the mirrored MeshCore list (`meshcore-upstream.json`). +RadioPresetParseResult parseUpstreamPresets(String source) { + final doc = _decodeObject(source, kUpstreamPresetFormat); + final srs = doc['suggested_radio_settings']; + final entries = srs is Map ? srs['entries'] : null; + if (entries is! List) { + throw const RadioPresetFormatException( + 'suggested_radio_settings.entries must be a list', + ); + } + final presets = []; + var skipped = 0; + for (final e in entries) { + final preset = e is Map ? _upstreamEntry(e) : null; + if (preset == null) { + skipped++; + } else { + presets.add(preset); + } + } + return RadioPresetParseResult(presets, skipped); +} + +/// Parse the Offband overlay (`offband.json`). Retired entries are dropped. +RadioPresetParseResult parseOverlayPresets(String source) { + final doc = _decodeObject(source, kOverlayPresetFormat); + final entries = doc['presets']; + if (entries is! List) { + throw const RadioPresetFormatException('presets must be a list'); + } + final presets = []; + var skipped = 0; + for (final e in entries) { + if (e is Map && e['status'] == 'retired') continue; + final preset = e is Map ? _overlayEntry(e) : null; + if (preset == null) { + skipped++; + } else { + presets.add(preset); + } + } + return RadioPresetParseResult(presets, skipped); +} + +Map _decodeObject(String source, String format) { + final dynamic doc; + try { + doc = jsonDecode(source); + } on FormatException catch (e) { + throw RadioPresetFormatException('not valid JSON: ${e.message}'); + } + if (doc is! Map) { + throw const RadioPresetFormatException('must be a JSON object'); + } + if (doc['format'] != format) { + throw RadioPresetFormatException('format must be "$format"'); + } + final version = doc['format_version']; + if (version is! int) { + throw const RadioPresetFormatException('format_version is required'); + } + if (version > kRadioPresetFormatVersion) { + throw RadioPresetFormatException( + 'format_version $version is newer than this app supports ' + '($kRadioPresetFormatVersion). Update the app.', + ); + } + return doc; +} + +RadioPreset? _upstreamEntry(Map e) { + final title = e['title']; + if (title is! String || title.trim().isEmpty) return null; + final ns = e['network_settings']; + final hash = ns is Map ? _asInt(ns['path_hash_size']) : null; + if (ns is Map && ns.containsKey('path_hash_size') && !_validHash(hash)) { + return null; + } + return _build( + id: 'meshcore:$title', + title: title, + region: upstreamRegionFor(title), + frequency: _asDouble(e['frequency']), + bandwidthKHz: _asDouble(e['bandwidth']), + sf: _asInt(e['spreading_factor']), + cr: _asInt(e['coding_rate']), + source: RadioPresetSource.meshcore, + pathHashBytes: hash, + ); +} + +RadioPreset? _overlayEntry(Map e) { + final id = e['id']; + final title = e['title']; + final region = e['region']; + if (id is! String || id.isEmpty) return null; + if (title is! String || title.trim().isEmpty) return null; + if (region is! String || region.trim().isEmpty) return null; + // The overlay is hand-edited: numbers must be JSON numbers, not strings. + for (final key in const [ + 'frequency', + 'bandwidth', + 'spreading_factor', + 'coding_rate', + ]) { + if (e[key] is! num) return null; + } + final tx = e['tx_power']; + if (tx != null && (tx is! int || tx < -9 || tx > 30)) return null; + final hash = e['path_hash_size']; + if (hash != null && (hash is! int || !_validHash(hash))) return null; + final offGrid = e['off_grid']; + if (offGrid != null && offGrid is! bool) return null; + return _build( + id: 'offband:$id', + title: title, + region: region, + frequency: (e['frequency'] as num).toDouble(), + bandwidthKHz: (e['bandwidth'] as num).toDouble(), + sf: _asInt(e['spreading_factor']), + cr: _asInt(e['coding_rate']), + source: RadioPresetSource.offband, + txPowerDbm: tx as int?, + pathHashBytes: hash as int?, + offGrid: offGrid == true, + ); +} + +RadioPreset? _build({ + required String id, + required String title, + required String region, + required double? frequency, + required double? bandwidthKHz, + required int? sf, + required int? cr, + required RadioPresetSource source, + int? txPowerDbm, + int? pathHashBytes, + bool offGrid = false, +}) { + if (frequency == null || frequency < 150 || frequency > 2500) return null; + if (bandwidthKHz == null) return null; + final bwHz = (bandwidthKHz * 1000).round(); + final bandwidth = LoRaBandwidth.values + .where((b) => (b.hz - bwHz).abs() <= 50) + .firstOrNull; + final spreading = LoRaSpreadingFactor.values + .where((s) => s.value == sf) + .firstOrNull; + final coding = LoRaCodingRate.values.where((c) => c.value == cr).firstOrNull; + if (bandwidth == null || spreading == null || coding == null) return null; + return RadioPreset( + id: id, + title: title.trim(), + region: region.trim(), + frequencyMHz: frequency, + bandwidth: bandwidth, + spreadingFactor: spreading, + codingRate: coding, + source: source, + txPowerDbm: txPowerDbm, + pathHashBytes: pathHashBytes, + offGrid: offGrid, + ); +} + +bool _validHash(int? bytes) => bytes != null && bytes >= 1 && bytes <= 3; + +double? _asDouble(Object? v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v.trim()); + return null; +} + +int? _asInt(Object? v) { + final d = _asDouble(v); + if (d == null || d != d.roundToDouble()) return null; + return d.round(); +} + +/// The picker group for an upstream title, which carries no region field: +/// the part before " - ", ":" or " (" ("USA - Southern California" -> "USA", +/// "Australia: QLD" -> "Australia", "EU/UK (Narrow)" -> "EU/UK"), otherwise the +/// title without a trailing band number ("Portugal 433" -> "Portugal"), +/// otherwise the whole title ("Costa Rica"). +String upstreamRegionFor(String title) { + final t = title.trim(); + var cut = t.length; + for (final sep in const [' - ', ':', ' (']) { + final i = t.indexOf(sep); + if (i > 0 && i < cut) cut = i; + } + if (cut < t.length) return t.substring(0, cut).trim(); + final band = RegExp(r'^(.+?)\s+\d+$').firstMatch(t); + return band != null ? band.group(1)! : t; +} diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index e74cf209..9e8efe10 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -1,3 +1,6 @@ +// LoRa radio parameter values. The regional presets that combine them live in +// radio_preset.dart and come from OffbandMesh/config-profiles (#647). + enum LoRaBandwidth { bw7_8(7800, '7.8 kHz'), bw10_4(10400, '10.4 kHz'), @@ -43,489 +46,3 @@ enum LoRaCodingRate { const LoRaCodingRate(this.value, this.label); } - -class RadioSettings { - final double frequencyMHz; - final LoRaBandwidth bandwidth; - final LoRaSpreadingFactor spreadingFactor; - final LoRaCodingRate codingRate; - final int txPowerDbm; - - RadioSettings({ - required this.frequencyMHz, - required this.bandwidth, - required this.spreadingFactor, - required this.codingRate, - required this.txPowerDbm, - }); - - // Regional preset configurations - static final List<(String, RadioSettings)> presets = [ - ( - 'Australia', - RadioSettings( - frequencyMHz: 915.8, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf10, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Australia (Narrow)', - RadioSettings( - frequencyMHz: 916.575, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Australia SA, WA, QLD', - RadioSettings( - frequencyMHz: 923.125, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Czech Republic', - RadioSettings( - frequencyMHz: 869.432, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'EU 433MHz', - RadioSettings( - frequencyMHz: 433.650, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'EU/UK (Long Range)', - RadioSettings( - frequencyMHz: 869.525, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'EU/UK (Medium Range)', - RadioSettings( - frequencyMHz: 869.525, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf10, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'EU/UK (Narrow)', - RadioSettings( - frequencyMHz: 869.618, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'New Zealand', - RadioSettings( - frequencyMHz: 917.375, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'New Zealand (Narrow)', - RadioSettings( - frequencyMHz: 917.375, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Portugal 433', - RadioSettings( - frequencyMHz: 433.375, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf9, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Portugal 869', - RadioSettings( - frequencyMHz: 869.618, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'Russia Artyom (VVO)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Biysk (BSK)', - RadioSettings( - frequencyMHz: 869.000, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Russia Chelyabinsk (CEK)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Cherepovets (CEE)', - RadioSettings( - frequencyMHz: 868.570, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Irkutsk (IKT)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Ivanovo (IWA)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Izhevsk (IJK)', - RadioSettings( - frequencyMHz: 868.732, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Kaluga (KLF)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Kazan (KZN)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Khabarovsk (KHV)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Kirov (KVX)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Lipetsk (LPK)', - RadioSettings( - frequencyMHz: 868.950, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf9, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Moscow (MOW)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Nizhny Novgorod (GOJ)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Novosibirsk (OVB)', - RadioSettings( - frequencyMHz: 869.000, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf9, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Rostov-on-Don (ROV)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf9, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Ryazan (RZN)', - RadioSettings( - frequencyMHz: 868.880, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf9, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Russia Samara (KUF)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Saratov (GSV)', - RadioSettings( - frequencyMHz: 864.281, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia St. Petersburg (LED)', - RadioSettings( - frequencyMHz: 868.856, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Tambov (TBW)', - RadioSettings( - frequencyMHz: 868.950, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf10, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Russia Tula (TYA)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Tver (KLD)', - RadioSettings( - frequencyMHz: 869.169, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Ufa (UFA)', - RadioSettings( - frequencyMHz: 868.732, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Russia Volgograd (VOG)', - RadioSettings( - frequencyMHz: 869.525, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Russia Voronezh (VOZ)', - RadioSettings( - frequencyMHz: 868.731, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_6, - txPowerDbm: 20, - ), - ), - ( - 'Russia Yekaterinburg (SVX)', - RadioSettings( - frequencyMHz: 869.046, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_7, - txPowerDbm: 20, - ), - ), - ( - 'Switzerland', - RadioSettings( - frequencyMHz: 869.618, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf8, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 14, - ), - ), - ( - 'USA Arizona', - RadioSettings( - frequencyMHz: 908.205, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf10, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'USA/Canada', - RadioSettings( - frequencyMHz: 910.525, - bandwidth: LoRaBandwidth.bw62_5, - spreadingFactor: LoRaSpreadingFactor.sf7, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - ( - 'Vietnam', - RadioSettings( - frequencyMHz: 920.250, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_5, - txPowerDbm: 20, - ), - ), - // Off-grid repeat presets (valid client_repeat frequencies) - ( - 'Off-Grid 433', - RadioSettings( - frequencyMHz: 433.0, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ( - 'Off-Grid 869', - RadioSettings( - frequencyMHz: 869.0, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 14, - ), - ), - ( - 'Off-Grid 918', - RadioSettings( - frequencyMHz: 918.0, - bandwidth: LoRaBandwidth.bw250, - spreadingFactor: LoRaSpreadingFactor.sf11, - codingRate: LoRaCodingRate.cr4_8, - txPowerDbm: 20, - ), - ), - ]; - - int get frequencyHz => (frequencyMHz * 1000).round(); - int get bandwidthHz => bandwidth.hz; - int get sf => spreadingFactor.value; - int get cr => codingRate.value; -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12803c63..9b6f9c14 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -13,9 +13,11 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/offband_gps_status.dart'; +import '../models/radio_preset.dart'; import '../models/radio_settings.dart'; import '../services/app_debug_log_service.dart'; import '../services/app_settings_service.dart'; +import '../services/radio_preset_service.dart'; import '../connector/observer_config_client.dart'; import '../helpers/snack_bar_builder.dart'; import '../utils/build_info.dart'; @@ -32,6 +34,7 @@ import 'import_config_screen.dart'; import 'serial_capture_screen.dart'; import 'topology_debug_screen.dart'; import 'companion_radio_stats_screen.dart'; +import '../widgets/radio_preset_picker.dart'; import '../widgets/sync_progress_overlay.dart'; /// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) @@ -800,6 +803,11 @@ class _SettingsScreenState extends State { l10n.settings_aboutLegalese, style: Theme.of(context).textTheme.bodySmall, ), + const SizedBox(height: 4), + Text( + l10n.settings_aboutPresetsAttribution, + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ), @@ -2029,15 +2037,19 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5; final _txPowerController = TextEditingController(text: '20'); bool _clientRepeat = false; - int? _selectedPresetIndex; + String? _selectedPresetId; _RadioSettingsSnapshot? _lastNonRepeatSnapshot; + late final RadioPresetService _presetService; AppDebugLogService get _appLog => Provider.of(context, listen: false); + List get _presets => _presetService.presets; + @override void initState() { super.initState(); + _presetService = Provider.of(context, listen: false); // Populate with current settings if available if (widget.connector.currentFreqHz != null) { @@ -2085,64 +2097,95 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { } _clientRepeat = widget.connector.clientRepeat ?? false; - _selectedPresetIndex = _findMatchingPresetIndex(); + _selectedPresetId = _findMatchingPresetId(); if (_clientRepeat) { _lastNonRepeatSnapshot = _sessionRememberedNonRepeatSnapshot() ?? _inferNonRepeatSnapshotForRepeatEnabled(); - _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _selectedPresetId = _findMatchingPresetIdForSnapshot( _lastNonRepeatSnapshot!, ); } else { _lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection(); } + _presetService.addListener(_onPresetsChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _logRadioSettingsState('Dialog initialized'); + _presetService.refreshIfStale(); }); } @override void dispose() { + _presetService.removeListener(_onPresetsChanged); _frequencyController.dispose(); _txPowerController.dispose(); super.dispose(); } - void _applyPreset(int index) { + /// A refresh can change the list under an open form; re-match the selection. + void _onPresetsChanged() { + if (!mounted) return; + setState(_syncPresetSelection); + } + + RadioPreset? _presetById(String? id) { + if (id == null) return null; + return _presets.where((p) => p.id == id).firstOrNull; + } + + void _applyPreset(RadioPreset preset) { setState(() { - _applyPresetState(index); + _applyPresetState(preset); }); - _logRadioSettingsState( - 'Applied preset ${RadioSettings.presets[index].$1} (#$index)', - ); + _logRadioSettingsState('Applied preset ${preset.title} (${preset.id})'); } - int? _findMatchingPresetIndex() { - return _findMatchingPresetIndexForSnapshot(_currentSnapshot()); + String? _findMatchingPresetId() { + return _findMatchingPresetIdForSnapshot(_currentSnapshot()); } - int? _findMatchingPresetIndexForSnapshot(_RadioSettingsSnapshot snapshot) { - for (final i in _visiblePresetIndexes()) { - final preset = RadioSettings.presets[i].$2; - if (preset.frequencyHz == snapshot.frequencyHz && - preset.bandwidth == snapshot.bandwidth && - preset.spreadingFactor == snapshot.spreadingFactor && - preset.codingRate == snapshot.codingRate && - preset.txPowerDbm == snapshot.txPowerDbm) { - return i; + /// A preset without TX power (every upstream one) matches any power. + bool _presetMatches( + RadioPreset preset, + _RadioSettingsSnapshot snapshot, { + int? frequencyHz, + }) { + // _RadioSettingsSnapshot.frequencyHz is also kHz, despite its name. + return (frequencyHz ?? preset.frequencyKHz) == snapshot.frequencyHz && + preset.bandwidth == snapshot.bandwidth && + preset.spreadingFactor == snapshot.spreadingFactor && + preset.codingRate == snapshot.codingRate && + (preset.txPowerDbm == null || + preset.txPowerDbm == snapshot.txPowerDbm) && + presetPathHashMatches(preset, widget.connector.pathHashByteWidth); + } + + String? _findMatchingPresetIdForSnapshot(_RadioSettingsSnapshot snapshot) { + for (final preset in _visiblePresets()) { + if (_presetMatches(preset, snapshot)) { + return preset.id; } } return null; } - Iterable _visiblePresetIndexes() sync* { - for (var i = 0; i < RadioSettings.presets.length; i++) { - if (_isOffGridPresetIndex(i)) { - continue; - } - yield i; - } + Iterable _visiblePresets() => _presets.where((p) => !p.offGrid); + + /// The snapshot a preset sets; TX power stays as it is when the preset has + /// none. + _RadioSettingsSnapshot _snapshotForPreset( + RadioPreset preset, + int currentTxPowerDbm, + ) { + return _RadioSettingsSnapshot( + frequencyMHz: preset.frequencyMHz, + bandwidth: preset.bandwidth, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate, + txPowerDbm: preset.txPowerDbm ?? currentTxPowerDbm, + ); } _RadioSettingsSnapshot _currentSnapshot() { @@ -2157,10 +2200,7 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { ); } - bool _isOffGridPresetIndex(int? index) { - if (index == null) return false; - return RadioSettings.presets[index].$1.startsWith('Off-Grid '); - } + bool _isOffGridPreset(String? id) => _presetById(id)?.offGrid ?? false; double _offGridFrequencyForBaseFrequency(double baseFrequencyMHz) { if (baseFrequencyMHz < 500) return 433.0; @@ -2188,7 +2228,7 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { _RadioSettingsSnapshot _nonRepeatSnapshotForCurrentSelection() { final current = _currentSnapshot(); - if (!_isOffGridPresetIndex(_selectedPresetIndex)) { + if (!_isOffGridPreset(_selectedPresetId)) { return current; } return _fallbackNonRepeatSnapshot(current.frequencyMHz); @@ -2202,23 +2242,12 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { _RadioSettingsSnapshot _inferNonRepeatSnapshotForRepeatEnabled() { final current = _currentSnapshot(); - for (final i in _visiblePresetIndexes()) { - final preset = RadioSettings.presets[i].$2; + for (final preset in _visiblePresets()) { final offGridFreqHz = (_offGridFrequencyForBaseFrequency(preset.frequencyMHz) * 1000) .round(); - if (offGridFreqHz == current.frequencyHz && - preset.bandwidth == current.bandwidth && - preset.spreadingFactor == current.spreadingFactor && - preset.codingRate == current.codingRate && - preset.txPowerDbm == current.txPowerDbm) { - return _RadioSettingsSnapshot( - frequencyMHz: preset.frequencyMHz, - bandwidth: preset.bandwidth, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate, - txPowerDbm: preset.txPowerDbm, - ); + if (_presetMatches(preset, current, frequencyHz: offGridFreqHz)) { + return _snapshotForPreset(preset, current.txPowerDbm); } } return _fallbackNonRepeatSnapshot(current.frequencyMHz); @@ -2232,14 +2261,10 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { _txPowerController.text = snapshot.txPowerDbm.toString(); } - void _applyPresetState(int index) { - final preset = RadioSettings.presets[index].$2; - final baseSnapshot = _RadioSettingsSnapshot( - frequencyMHz: preset.frequencyMHz, - bandwidth: preset.bandwidth, - spreadingFactor: preset.spreadingFactor, - codingRate: preset.codingRate, - txPowerDbm: preset.txPowerDbm, + void _applyPresetState(RadioPreset preset) { + final baseSnapshot = _snapshotForPreset( + preset, + int.tryParse(_txPowerController.text) ?? 20, ); final frequencyMHz = _clientRepeat ? _offGridFrequencyForBaseFrequency(baseSnapshot.frequencyMHz) @@ -2248,13 +2273,13 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { _bandwidth = preset.bandwidth; _spreadingFactor = preset.spreadingFactor; _codingRate = preset.codingRate; - _txPowerController.text = preset.txPowerDbm.toString(); - _selectedPresetIndex = index; + _txPowerController.text = baseSnapshot.txPowerDbm.toString(); + _selectedPresetId = preset.id; _lastNonRepeatSnapshot = baseSnapshot; } void _syncPresetSelection() { - final previousPresetIndex = _selectedPresetIndex; + final previousPresetId = _selectedPresetId; final previousLastNonRepeat = _lastNonRepeatSnapshot; if (_clientRepeat) { final baseSnapshot = @@ -2272,25 +2297,25 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { txPowerDbm: int.tryParse(_txPowerController.text) ?? 20, ); } - _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _selectedPresetId = _findMatchingPresetIdForSnapshot( _lastNonRepeatSnapshot ?? baseSnapshot, ); - if (previousPresetIndex != _selectedPresetIndex || + if (previousPresetId != _selectedPresetId || previousLastNonRepeat != _lastNonRepeatSnapshot) { _logRadioSettingsState( - 'Preset match updated while repeat enabled: ${_presetLabel(previousPresetIndex)} -> ${_presetLabel(_selectedPresetIndex)}', + 'Preset match updated while repeat enabled: ${_presetLabel(previousPresetId)} -> ${_presetLabel(_selectedPresetId)}', ); } return; } _lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection(); - _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _selectedPresetId = _findMatchingPresetIdForSnapshot( _lastNonRepeatSnapshot!, ); - if (previousPresetIndex != _selectedPresetIndex || + if (previousPresetId != _selectedPresetId || previousLastNonRepeat != _lastNonRepeatSnapshot) { _logRadioSettingsState( - 'Preset sync updated state from ${_presetLabel(previousPresetIndex)} to ${_presetLabel(_selectedPresetIndex)}', + 'Preset sync updated state from ${_presetLabel(previousPresetId)} to ${_presetLabel(_selectedPresetId)}', ); } } @@ -2385,6 +2410,15 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { ), ); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); + // A preset that carries a path hash size sets it too (stock + // CMD_SET_PATH_HASH_MODE, #730). Sent whenever the preset has one, + // rather than compared against the reported width, which reads as 1 + // when device info is short (#240). Setting the same mode is harmless. + final pathHashMode = _presetById(_selectedPresetId)?.pathHashMode; + if (pathHashMode != null) { + _logRadioSettingsState('Setting preset path hash mode $pathHashMode'); + await widget.connector.setPathHashMode(pathHashMode); + } await widget.connector.refreshDeviceInfo(); final rememberedSnapshot = _clientRepeat ? _lastNonRepeatSnapshot @@ -2411,11 +2445,12 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { } } - String _presetLabel(int? index) { - if (index == null) { + String _presetLabel(String? id) { + if (id == null) { return 'custom'; } - return '${RadioSettings.presets[index].$1} (#$index)'; + final preset = _presetById(id); + return preset == null ? id : '${preset.title} ($id)'; } String _formatSnapshot(_RadioSettingsSnapshot? snapshot) { @@ -2439,7 +2474,7 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { 'cr=${_codingRate.label} ' 'tx=${_txPowerController.text}dBm ' 'repeat=$_clientRepeat ' - 'preset=${_presetLabel(_selectedPresetIndex)} ' + 'preset=${_presetLabel(_selectedPresetId)} ' 'lastNonRepeat=${_formatSnapshot(_lastNonRepeatSnapshot)}', tag: 'RadioSettings', ); @@ -2448,31 +2483,18 @@ class _RadioSettingsFormState extends State<_RadioSettingsForm> { @override Widget build(BuildContext context) { final l10n = context.l10n; + final presetService = context.watch(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - DropdownButtonFormField( - key: ValueKey(_selectedPresetIndex), - initialValue: _selectedPresetIndex, - decoration: InputDecoration( - labelText: l10n.settings_presets, - border: const OutlineInputBorder(), - ), - items: [ - for (final i in _visiblePresetIndexes()) - DropdownMenuItem( - value: i, - child: Text(RadioSettings.presets[i].$1), - ), - ], - onChanged: (index) { - if (index != null) { - _applyPreset(index); - } - }, + RadioPresetPicker( + service: presetService, + presets: _visiblePresets().toList(), + selectedId: _selectedPresetId, + onSelected: _applyPreset, ), - const SizedBox(height: 16), + const SizedBox(height: 8), TextField( controller: _frequencyController, onChanged: (_) => _handleManualSettingsChanged('frequency'), diff --git a/lib/services/config_source_service.dart b/lib/services/config_source_service.dart index 0ea976d9..90a3b1dc 100644 --- a/lib/services/config_source_service.dart +++ b/lib/services/config_source_service.dart @@ -76,6 +76,10 @@ class ConfigSourceService { return parseConfigProfile(await _get(url)); } + /// Fetch a file's raw text (radio presets, #728). Same cache-busting and + /// failure messages as the catalog and profile fetches. + Future fetchText(String url) => _get(url); + Future _get(String url) async { final http.Response resp; try { diff --git a/lib/services/radio_preset_service.dart b/lib/services/radio_preset_service.dart new file mode 100644 index 00000000..097b3e20 --- /dev/null +++ b/lib/services/radio_preset_service.dart @@ -0,0 +1,217 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/radio_preset.dart'; +import '../storage/prefs_manager.dart'; +import '../utils/app_logger.dart'; +import 'config_source_service.dart'; + +/// Where the preset files are published (OffbandMesh/config-profiles, #721). +const String kRadioPresetBaseUrl = + 'https://raw.githubusercontent.com/OffbandMesh/config-profiles/main/radio-presets/'; +const String kUpstreamPresetFile = 'meshcore-upstream.json'; +const String kOverlayPresetFile = 'offband.json'; + +/// The copies bundled with the app, so the picker works offline. +const String kRadioPresetAssetDir = 'assets/radio_presets/'; + +const String _prefsPrefix = 'radio_presets_v1_'; +const String _prefsRefreshedKey = '${_prefsPrefix}refreshed_at'; + +typedef PresetTextLoader = Future Function(String location); + +/// The regional radio preset list (#728): MeshCore's upstream presets plus +/// Offband's overlay, merged with the overlay winning on a matching title. +/// +/// Starts from the last good copy (or the bundled one), and refreshes from +/// config-profiles on demand. A file that fails to fetch or parse never +/// replaces a good copy; the failure is logged and kept in [refreshError] so +/// the picker can show it. +class RadioPresetService extends ChangeNotifier { + RadioPresetService({ + PresetTextLoader? fetch, + PresetTextLoader? loadAsset, + SharedPreferences? prefs, + DateTime Function()? clock, + }) : _fetch = fetch, + _loadAsset = loadAsset ?? rootBundle.loadString, + _prefsOverride = prefs, + _clock = clock ?? DateTime.now; + + final PresetTextLoader? _fetch; + final PresetTextLoader _loadAsset; + final SharedPreferences? _prefsOverride; + final DateTime Function() _clock; + ConfigSourceService? _source; + + SharedPreferences get _prefs => _prefsOverride ?? PrefsManager.instance; + + RadioPresetParseResult _upstream = const RadioPresetParseResult([], 0); + RadioPresetParseResult _overlay = const RadioPresetParseResult([], 0); + List _presets = const []; + String? _refreshError; + DateTime? _lastRefreshed; + bool _refreshing = false; + + List get presets => _presets; + + /// Entries left out of the files in use because they were malformed. + int get skipped => _upstream.skipped + _overlay.skipped; + + /// Why the last refresh (or load) failed, or null when it succeeded. + String? get refreshError => _refreshError; + DateTime? get lastRefreshed => _lastRefreshed; + bool get refreshing => _refreshing; + + /// Load the last good copy of each file, falling back to the bundled one. + Future load() async { + final errors = []; + _upstream = await _loadLocal( + kUpstreamPresetFile, + parseUpstreamPresets, + errors, + ); + _overlay = await _loadLocal( + kOverlayPresetFile, + parseOverlayPresets, + errors, + ); + final stamp = _prefs.getString(_prefsRefreshedKey); + _lastRefreshed = stamp == null ? null : DateTime.tryParse(stamp); + _refreshError = errors.isEmpty ? null : errors.join('; '); + _rebuild(); + } + + /// Refresh when the copy in use is older than [maxAge] or was never + /// refreshed. Returns whether a refresh ran and succeeded. + Future refreshIfStale({ + Duration maxAge = const Duration(hours: 12), + }) async { + final last = _lastRefreshed; + if (last != null && _clock().difference(last) < maxAge) return false; + return refresh(); + } + + /// Fetch both files from config-profiles. Each file that parses replaces + /// its copy and is saved as the new last good copy; each that fails keeps + /// the copy in use. Returns true only when both succeeded. + Future refresh() async { + if (_refreshing) return false; + _refreshing = true; + notifyListeners(); + final errors = []; + final upstream = await _fetchRemote( + kUpstreamPresetFile, + parseUpstreamPresets, + errors, + ); + final overlay = await _fetchRemote( + kOverlayPresetFile, + parseOverlayPresets, + errors, + ); + if (upstream != null) _upstream = upstream; + if (overlay != null) _overlay = overlay; + if (errors.isEmpty) { + _lastRefreshed = _clock(); + await _prefs.setString( + _prefsRefreshedKey, + _lastRefreshed!.toIso8601String(), + ); + _refreshError = null; + } else { + _refreshError = errors.join('; '); + } + _refreshing = false; + _rebuild(); + return errors.isEmpty; + } + + Future _loadLocal( + String file, + RadioPresetParseResult Function(String) parse, + List errors, + ) async { + final cached = _prefs.getString('$_prefsPrefix$file'); + if (cached != null) { + try { + return parse(cached); + } catch (e) { + appLogger.warn( + 'Saved copy of $file unusable, using the bundled one: $e', + tag: 'RadioPresets', + ); + } + } + try { + return parse(await _loadAsset('$kRadioPresetAssetDir$file')); + } catch (e) { + appLogger.error('Bundled $file failed to load: $e', tag: 'RadioPresets'); + errors.add('$file: $e'); + return const RadioPresetParseResult([], 0); + } + } + + Future _fetchRemote( + String file, + RadioPresetParseResult Function(String) parse, + List errors, + ) async { + final url = '$kRadioPresetBaseUrl$file'; + try { + final text = _fetch != null + ? await _fetch(url) + : await (_source ??= ConfigSourceService()).fetchText(url); + final parsed = parse(text); + if (parsed.presets.isEmpty) { + throw RadioPresetFormatException('$file has no usable presets'); + } + await _prefs.setString('$_prefsPrefix$file', text); + if (parsed.skipped > 0) { + appLogger.warn( + '$file: ${parsed.skipped} malformed preset(s) skipped', + tag: 'RadioPresets', + ); + } + return parsed; + } catch (e) { + appLogger.warn('Refreshing $file failed: $e', tag: 'RadioPresets'); + errors.add('$file: $e'); + return null; + } + } + + void _rebuild() { + _presets = mergeRadioPresets(_upstream.presets, _overlay.presets); + notifyListeners(); + } + + @override + void dispose() { + _source?.dispose(); + super.dispose(); + } +} + +/// Upstream presets plus the overlay. An overlay entry whose title matches an +/// upstream one (ignoring case) replaces it. Sorted by region, then title. +List mergeRadioPresets( + List upstream, + List overlay, +) { + final byTitle = { + for (final p in upstream) p.title.toLowerCase(): p, + }; + for (final p in overlay) { + byTitle[p.title.toLowerCase()] = p; + } + final merged = byTitle.values.toList() + ..sort((a, b) { + final r = a.region.toLowerCase().compareTo(b.region.toLowerCase()); + return r != 0 + ? r + : a.title.toLowerCase().compareTo(b.title.toLowerCase()); + }); + return merged; +} diff --git a/lib/widgets/radio_preset_picker.dart b/lib/widgets/radio_preset_picker.dart new file mode 100644 index 00000000..642f1e92 --- /dev/null +++ b/lib/widgets/radio_preset_picker.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +import '../l10n/l10n.dart'; +import '../models/radio_preset.dart'; +import '../services/radio_preset_service.dart'; + +/// The regional preset picker (#729): presets grouped by region under +/// non-selectable headers, each labelled with its source, plus a status line +/// saying where the list came from (and any refresh failure) with a refresh +/// button. Used by the companion Radio Settings form and the remote-node +/// settings screen. +class RadioPresetPicker extends StatelessWidget { + const RadioPresetPicker({ + super.key, + required this.service, + required this.presets, + required this.selectedId, + required this.onSelected, + }); + + final RadioPresetService service; + + /// The presets to offer, already filtered and in display order (grouped + /// by region). + final List presets; + final String? selectedId; + final ValueChanged onSelected; + + static const _regionHeaderPrefix = 'region:'; + + RadioPreset? _byId(String? id) => + id == null ? null : presets.where((p) => p.id == id).firstOrNull; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final selected = _byId(selectedId)?.id; + final titlesById = {for (final p in presets) p.id: p.title}; + final items = >[]; + String? region; + for (final preset in presets) { + if (preset.region != region) { + region = preset.region; + items.add( + DropdownMenuItem( + value: '$_regionHeaderPrefix$region', + enabled: false, + child: Text( + region, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + items.add( + DropdownMenuItem( + value: preset.id, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: Row( + children: [ + Expanded( + child: Text(preset.title, overflow: TextOverflow.ellipsis), + ), + const SizedBox(width: 8), + Text( + switch (preset.source) { + RadioPresetSource.meshcore => + l10n.settings_presetSourceMeshCore, + RadioPresetSource.offband => + l10n.settings_presetSourceOffband, + }, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + key: ValueKey(selected), + initialValue: selected, + isExpanded: true, + decoration: InputDecoration( + labelText: l10n.settings_presets, + border: const OutlineInputBorder(), + ), + selectedItemBuilder: (context) => [ + for (final item in items) + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + titlesById[item.value] ?? '', + overflow: TextOverflow.ellipsis, + ), + ), + ], + items: items, + // Disabled while a refresh runs, so a pick can't vanish when the + // refreshed list lands. + onChanged: service.refreshing + ? null + : (id) { + final preset = _byId(id); + if (preset != null) onSelected(preset); + }, + ), + _PresetStatus(service: service), + ], + ); + } +} + +/// Where the list came from, and any refresh failure, kept on screen until +/// the next successful refresh. +class _PresetStatus extends StatelessWidget { + const _PresetStatus({required this.service}); + + final RadioPresetService service; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final String text; + Color? color; + if (service.refreshing) { + text = l10n.settings_presetsUpdating; + } else if (service.refreshError != null) { + text = l10n.settings_presetsUpdateFailed; + color = theme.colorScheme.error; + } else if (service.lastRefreshed != null) { + text = l10n.settings_presetsUpdatedAt( + MaterialLocalizations.of( + context, + ).formatMediumDate(service.lastRefreshed!.toLocal()), + ); + } else { + text = l10n.settings_presetsBundled; + } + return Row( + children: [ + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: color ?? theme.colorScheme.onSurfaceVariant, + ), + ), + ), + IconButton( + tooltip: l10n.settings_presetsRefresh, + onPressed: service.refreshing ? null : service.refresh, + icon: service.refreshing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 27c9fecb..27a36a23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -146,6 +146,7 @@ flutter: assets: - assets/images/ - assets/icons/ + - assets/radio_presets/ flutter_launcher_icons: android: true diff --git a/test/connector/preset_apply_frames_test.dart b/test/connector/preset_apply_frames_test.dart new file mode 100644 index 00000000..a810652d --- /dev/null +++ b/test/connector/preset_apply_frames_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/services/radio_preset_service.dart'; + +/// What applying a preset puts on the wire to a companion or observer +/// (#730): stock CMD_SET_RADIO_PARAMS (11), CMD_SET_RADIO_TX_POWER (12) and, +/// when the preset carries one, CMD_SET_PATH_HASH_MODE (61). +void main() { + late List presets; + + setUpAll(() { + presets = mergeRadioPresets( + parseUpstreamPresets( + File('assets/radio_presets/meshcore-upstream.json').readAsStringSync(), + ).presets, + parseOverlayPresets( + File('assets/radio_presets/offband.json').readAsStringSync(), + ).presets, + ); + }); + + RadioPreset named(String title) => + presets.firstWhere((p) => p.title == title); + + List le32(int v) => [ + v & 0xFF, + (v >> 8) & 0xFF, + (v >> 16) & 0xFF, + (v >> 24) & 0xFF, + ]; + + test('radio params: frequency in kHz, bandwidth in Hz, as stock expects', () { + final philly = named('USA - Philly Mesh'); + // The form sends MHz * 1000, i.e. kHz; stock stores freq / 1000.0 as MHz. + final frame = buildSetRadioParamsFrame( + philly.frequencyKHz, + philly.bandwidth.hz, + philly.spreadingFactor.value, + philly.codingRate.value, + ); + expect(frame, [cmdSetRadioParams, ...le32(919500), ...le32(500000), 10, 5]); + // Inside stock's accepted ranges (150000-2500000 kHz, BW <= 500000 Hz). + expect(philly.frequencyKHz, inInclusiveRange(150000, 2500000)); + expect(philly.bandwidth.hz, lessThanOrEqualTo(500000)); + }); + + test('path hash: published bytes become the zero-based mode on the wire', () { + expect( + buildSetPathHashModeFrame(named('USA - Philly Mesh').pathHashMode!), + [cmdSetPathHashMode, 0, 1], + ); + expect( + buildSetPathHashModeFrame( + named('USA - Southern California').pathHashMode!, + ), + [cmdSetPathHashMode, 0, 2], + ); + expect( + buildSetPathHashModeFrame(named('New Zealand (Gisborne)').pathHashMode!), + [cmdSetPathHashMode, 0, 0], + ); + }); + + test('a preset without path hash or TX power sends neither', () { + final usa = named('USA'); + expect(usa.pathHashMode, isNull); + expect(usa.txPowerDbm, isNull); + }); +} diff --git a/test/models/radio_preset_test.dart b/test/models/radio_preset_test.dart new file mode 100644 index 00000000..b2dd13d7 --- /dev/null +++ b/test/models/radio_preset_test.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/models/radio_settings.dart'; + +String upstream(List> entries, {int version = 1}) => + jsonEncode({ + 'format': kUpstreamPresetFormat, + 'format_version': version, + 'source': 'https://api.meshcore.nz/api/v1/config', + 'credit': 'Liam Cottle / MeshCore', + 'updated_at': '2026-10-01T00:00:00Z', + 'suggested_radio_settings': {'entries': entries}, + }); + +String overlay(List> presets, {int version = 1}) => + jsonEncode({ + 'format': kOverlayPresetFormat, + 'format_version': version, + 'presets': presets, + }); + +Map overlayEntry(String id, {Map? extra}) => { + 'id': id, + 'title': 'USA - $id', + 'region': 'USA', + 'frequency': 909.75, + 'bandwidth': 500, + 'spreading_factor': 10, + 'coding_rate': 5, + 'status': 'published', + ...?extra, +}; + +void main() { + group('parseUpstreamPresets (#727)', () { + test('reads the API shape, where numbers are strings', () { + final r = parseUpstreamPresets( + upstream([ + { + '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': 'EU/UK (Narrow)', + 'frequency': '869.618', + 'spreading_factor': '8', + 'bandwidth': '62.5', + 'coding_rate': '8', + }, + ]), + ); + expect(r.skipped, 0); + final socal = r.presets[0]; + expect(socal.id, 'meshcore:USA - Southern California'); + expect(socal.region, 'USA'); + expect(socal.frequencyKHz, 927875); + expect(socal.bandwidth, LoRaBandwidth.bw62_5); + expect(socal.spreadingFactor, LoRaSpreadingFactor.sf7); + expect(socal.codingRate, LoRaCodingRate.cr4_5); + expect(socal.pathHashBytes, 3); + expect(socal.txPowerDbm, isNull); + expect(socal.source, RadioPresetSource.meshcore); + expect(r.presets[1].codingRate, LoRaCodingRate.cr4_8); + expect(r.presets[1].pathHashBytes, isNull); + }); + + test('skips and counts malformed entries instead of failing', () { + final r = parseUpstreamPresets( + upstream([ + { + 'title': 'Good', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + }, + { + 'title': 'Bad BW', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '63', + 'coding_rate': '5', + }, + { + 'title': 'Bad hash', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + 'network_settings': {'path_hash_size': 4}, + }, + {'title': 'Missing values'}, + ]), + ); + expect(r.presets.map((p) => p.title), ['Good']); + expect(r.skipped, 3); + }); + + test('rejects a wrong format or a newer format_version', () { + expect( + () => parseUpstreamPresets(overlay([])), + throwsA(isA()), + ); + expect( + () => parseUpstreamPresets(upstream([], version: 2)), + throwsA(isA()), + ); + expect( + () => parseUpstreamPresets('502'), + throwsA(isA()), + ); + }); + }); + + group('parseOverlayPresets (#727)', () { + test( + 'reads overlay entries with optional TX power, path hash, off-grid', + () { + final r = parseOverlayPresets( + overlay([ + overlayEntry( + 'philly', + extra: {'tx_power': 20, 'path_hash_size': 2}, + ), + overlayEntry( + 'grid', + extra: { + 'region': 'Off-Grid', + 'frequency': 918.0, + 'bandwidth': 250, + 'spreading_factor': 11, + 'coding_rate': 8, + 'off_grid': true, + }, + ), + ]), + ); + expect(r.skipped, 0); + final philly = r.presets[0]; + expect(philly.id, 'offband:philly'); + expect(philly.bandwidth, LoRaBandwidth.bw500); + expect(philly.txPowerDbm, 20); + expect(philly.pathHashBytes, 2); + expect(philly.source, RadioPresetSource.offband); + expect(philly.offGrid, isFalse); + expect(r.presets[1].offGrid, isTrue); + expect(r.presets[1].region, 'Off-Grid'); + }, + ); + + test('drops retired entries without counting them as skipped', () { + final r = parseOverlayPresets( + overlay([ + overlayEntry('old', extra: {'status': 'retired'}), + overlayEntry('new'), + ]), + ); + expect(r.presets.map((p) => p.id), ['offband:new']); + expect(r.skipped, 0); + }); + + test('skips hand-edit mistakes: string numbers, bad TX, bad hash', () { + final r = parseOverlayPresets( + overlay([ + overlayEntry('a', extra: {'frequency': '909.75'}), + overlayEntry('b', extra: {'tx_power': 40}), + overlayEntry('c', extra: {'path_hash_size': '2'}), + overlayEntry('d', extra: {'coding_rate': 4}), + ]), + ); + expect(r.presets, isEmpty); + expect(r.skipped, 4); + }); + }); + + group('upstreamRegionFor (#727)', () { + test('groups upstream titles by their leading region', () { + expect(upstreamRegionFor('USA'), 'USA'); + expect(upstreamRegionFor('USA - Southern California'), 'USA'); + expect(upstreamRegionFor('Australia: QLD'), 'Australia'); + expect(upstreamRegionFor('Australia (Narrow)'), 'Australia'); + expect(upstreamRegionFor('EU/UK (Narrow)'), 'EU/UK'); + expect(upstreamRegionFor('Portugal 433'), 'Portugal'); + expect(upstreamRegionFor('Portugal 868'), 'Portugal'); + expect(upstreamRegionFor('Costa Rica'), 'Costa Rica'); + expect(upstreamRegionFor('New Zealand (Gisborne)'), 'New Zealand'); + }); + }); + + group('presetPathHashMatches (#747)', () { + final r = parseUpstreamPresets( + upstream([ + { + 'title': 'Canada', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + 'network_settings': {'path_hash_size': 3}, + }, + { + 'title': 'USA', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + }, + ]), + ); + final canada = r.presets[0]; + final usa = r.presets[1]; + + test('a preset with a path hash only fits a radio on that size', () { + expect(presetPathHashMatches(canada, 3), isTrue); + expect(presetPathHashMatches(canada, 2), isFalse); + expect(presetPathHashMatches(canada, 1), isFalse); + }); + + test('a preset without one fits any radio', () { + for (final bytes in [1, 2, 3]) { + expect(presetPathHashMatches(usa, bytes), isTrue); + } + }); + + test('a USA radio on 1- or 2-byte hashes is not mistaken for Canada', () { + // Same radio values; Canada sorts first by region, so without the + // path hash check it wins (the D1 screenshot). + for (final bytes in [1, 2]) { + final fitting = r.presets + .where((p) => presetPathHashMatches(p, bytes)) + .map((p) => p.title); + expect(fitting, ['USA']); + } + }); + }); + + group('path hash bytes to firmware mode (#649)', () { + test('published bytes map to the zero-based firmware mode', () { + expect(pathHashModeForBytes(1), 0); + expect(pathHashModeForBytes(2), 1); + expect(pathHashModeForBytes(3), 2); + }); + + test('a published 2 never becomes mode 2 (3-byte hashes)', () { + expect(pathHashModeForBytes(2), isNot(2)); + }); + + test('absent or out-of-range sizes send nothing', () { + expect(pathHashModeForBytes(null), isNull); + expect(pathHashModeForBytes(0), isNull); + expect(pathHashModeForBytes(4), isNull); + }); + + test('a parsed preset exposes its mode', () { + final r = parseUpstreamPresets( + upstream([ + { + 'title': 'Canada', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + 'network_settings': {'path_hash_size': 3}, + }, + { + 'title': 'USA', + 'frequency': '910.525', + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', + }, + ]), + ); + expect(r.presets[0].pathHashMode, 2); + expect(r.presets[1].pathHashMode, isNull); + }); + }); +} diff --git a/test/services/radio_preset_live_test.dart b/test/services/radio_preset_live_test.dart new file mode 100644 index 00000000..9f9cc730 --- /dev/null +++ b/test/services/radio_preset_live_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/services/config_source_service.dart'; +import 'package:meshcore_open/services/radio_preset_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Fetches the real preset files from config-profiles (#748). Every other +// preset test fakes the network, so a 404 on main passed them all and the +// owner saw "Couldn't update presets" on a test build twice. This one fails +// whenever the app's own refresh would. +void main() { + test( + 'refresh from config-profiles succeeds with no error', + () async { + SharedPreferences.setMockInitialValues({}); + final service = RadioPresetService( + prefs: await SharedPreferences.getInstance(), + loadAsset: (_) async => throw StateError('bundled copies not used'), + ); + addTearDown(service.dispose); + + final ok = await service.refresh(); + + expect(service.refreshError, isNull, reason: 'refresh reported an error'); + expect(ok, isTrue); + expect(service.skipped, 0); + final sources = service.presets.map((p) => p.source).toSet(); + expect(sources, {RadioPresetSource.meshcore, RadioPresetSource.offband}); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'a missing file on config-profiles is reported, not ignored', + () async { + final source = ConfigSourceService(); + addTearDown(source.dispose); + await expectLater( + source.fetchText('${kRadioPresetBaseUrl}missing-748.json'), + throwsA( + isA().having( + (e) => '$e', + 'message', + contains('HTTP 404'), + ), + ), + ); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); +} diff --git a/test/services/radio_preset_service_test.dart b/test/services/radio_preset_service_test.dart new file mode 100644 index 00000000..b385163c --- /dev/null +++ b/test/services/radio_preset_service_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/models/radio_settings.dart'; +import 'package:meshcore_open/services/radio_preset_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +String upstreamFile(List> entries) => jsonEncode({ + 'format': kUpstreamPresetFormat, + 'format_version': 1, + 'source': 'https://api.meshcore.nz/api/v1/config', + 'credit': 'Liam Cottle / MeshCore', + 'updated_at': '2026-10-01T00:00:00Z', + 'suggested_radio_settings': {'entries': entries}, +}); + +String overlayFile(List> presets) => jsonEncode({ + 'format': kOverlayPresetFormat, + 'format_version': 1, + 'presets': presets, +}); + +Map up(String title, {String freq = '910.525'}) => { + 'title': title, + 'frequency': freq, + 'spreading_factor': '7', + 'bandwidth': '62.5', + 'coding_rate': '5', +}; + +Map ov( + String id, + String title, { + double freq = 909.75, + String region = 'USA', +}) => { + 'id': id, + 'title': title, + 'region': region, + 'frequency': freq, + 'bandwidth': 500, + 'spreading_factor': 10, + 'coding_rate': 5, + 'status': 'published', +}; + +RadioPreset preset(String title, String region, RadioPresetSource source) => + RadioPreset( + id: '$source:$title', + title: title, + region: region, + frequencyMHz: 910.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + source: source, + ); + +void main() { + late SharedPreferences prefs; + final bundled = { + '${kRadioPresetAssetDir}meshcore-upstream.json': upstreamFile([ + up('USA'), + up('Canada'), + ]), + '${kRadioPresetAssetDir}offband.json': overlayFile([ + ov('oki', 'USA - OKI-Mesh 500kHz Test'), + ]), + }; + + Future loadBundled(String path) async { + final text = bundled[path]; + if (text == null) throw StateError('no asset $path'); + return text; + } + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + }); + + RadioPresetService service({ + Map? remote, + Object? remoteError, + DateTime Function()? clock, + }) => RadioPresetService( + prefs: prefs, + loadAsset: loadBundled, + clock: clock, + fetch: (url) async { + if (remoteError != null) throw remoteError; + final file = url.substring(kRadioPresetBaseUrl.length); + final text = remote?[file]; + if (text == null) throw StateError('HTTP 404 for $url'); + return text; + }, + ); + + group('mergeRadioPresets (#728)', () { + test('an overlay entry replaces the upstream one with the same title', () { + final merged = mergeRadioPresets( + [ + preset('USA', 'USA', RadioPresetSource.meshcore), + preset('Canada', 'Canada', RadioPresetSource.meshcore), + ], + [preset('usa', 'USA', RadioPresetSource.offband)], + ); + expect(merged.length, 2); + final usa = merged.firstWhere((p) => p.title.toLowerCase() == 'usa'); + expect(usa.source, RadioPresetSource.offband); + }); + + test('sorts by region, then title', () { + final merged = mergeRadioPresets( + [ + preset('USA', 'USA', RadioPresetSource.meshcore), + preset('Australia', 'Australia', RadioPresetSource.meshcore), + ], + [preset('USA - Arizona', 'USA', RadioPresetSource.offband)], + ); + expect(merged.map((p) => p.title), ['Australia', 'USA', 'USA - Arizona']); + }); + }); + + group('RadioPresetService (#728)', () { + test('offline: loads the bundled copies when nothing is saved', () async { + final s = service(); + await s.load(); + expect(s.presets.map((p) => p.title), [ + 'Canada', + 'USA', + 'USA - OKI-Mesh 500kHz Test', + ]); + expect(s.refreshError, isNull); + expect(s.lastRefreshed, isNull); + }); + + test('refresh replaces the list, saves it, and records the time', () async { + final now = DateTime.utc(2026, 10, 1, 12); + final s = service( + clock: () => now, + remote: { + kUpstreamPresetFile: upstreamFile([up('USA'), up('Brazil')]), + kOverlayPresetFile: overlayFile([ + ov('philly', 'USA - Philly Mesh', freq: 919.5), + ]), + }, + ); + await s.load(); + expect(await s.refresh(), isTrue); + expect(s.presets.map((p) => p.title), [ + 'Brazil', + 'USA', + 'USA - Philly Mesh', + ]); + expect(s.refreshError, isNull); + expect(s.lastRefreshed, now); + + // The saved copy is what the next launch starts from, even offline. + final next = service(remoteError: StateError('offline')); + await next.load(); + expect(next.presets.map((p) => p.title), contains('USA - Philly Mesh')); + expect(next.lastRefreshed, now); + }); + + test('an overlay edit reaches the app with no new build', () async { + final s = service( + remote: { + kUpstreamPresetFile: upstreamFile([up('USA')]), + kOverlayPresetFile: overlayFile([ + ov('oki', 'USA - OKI-Mesh 500kHz Test', freq: 911.5), + ]), + }, + ); + await s.load(); + expect( + s.presets.firstWhere((p) => p.id == 'offband:oki').frequencyMHz, + 909.75, + ); + await s.refresh(); + expect( + s.presets.firstWhere((p) => p.id == 'offband:oki').frequencyMHz, + 911.5, + ); + }); + + test('a failed refresh keeps the list in use and reports why', () async { + final s = service(remoteError: StateError('network down')); + await s.load(); + final before = s.presets.map((p) => p.title).toList(); + expect(await s.refresh(), isFalse); + expect(s.presets.map((p) => p.title), before); + expect(s.refreshError, contains('network down')); + expect(s.lastRefreshed, isNull); + expect(prefs.getKeys(), isEmpty); + }); + + test('a bad or empty file never replaces a good copy', () async { + final s = service( + remote: { + kUpstreamPresetFile: '502 Bad Gateway', + kOverlayPresetFile: overlayFile([]), + }, + ); + await s.load(); + expect(await s.refresh(), isFalse); + expect(s.presets.length, 3); + expect(s.refreshError, contains(kUpstreamPresetFile)); + expect(s.refreshError, contains(kOverlayPresetFile)); + }); + + test('one good file is still taken when the other fails', () async { + final s = service( + remote: { + kOverlayPresetFile: overlayFile([ov('az', 'USA - Arizona')]), + }, + ); + await s.load(); + expect(await s.refresh(), isFalse); + expect(s.presets.map((p) => p.title), ['Canada', 'USA', 'USA - Arizona']); + expect(s.refreshError, contains(kUpstreamPresetFile)); + }); + + test('malformed entries are skipped and counted', () async { + final s = service( + remote: { + kUpstreamPresetFile: upstreamFile([ + up('USA'), + {'title': 'Broken'}, + ]), + kOverlayPresetFile: overlayFile([ov('az', 'USA - Arizona')]), + }, + ); + await s.load(); + await s.refresh(); + expect(s.skipped, 1); + expect(s.presets.map((p) => p.title), isNot(contains('Broken'))); + }); + + test( + 'refreshIfStale only refreshes an old or never-refreshed copy', + () async { + var now = DateTime.utc(2026, 10, 1, 12); + var fetches = 0; + final s = RadioPresetService( + prefs: prefs, + loadAsset: loadBundled, + clock: () => now, + fetch: (url) async { + fetches++; + return url.endsWith(kUpstreamPresetFile) + ? upstreamFile([up('USA')]) + : overlayFile([ov('az', 'USA - Arizona')]); + }, + ); + await s.load(); + await s.refreshIfStale(); + expect(fetches, 2); + now = now.add(const Duration(hours: 1)); + await s.refreshIfStale(); + expect(fetches, 2); + now = now.add(const Duration(hours: 12)); + await s.refreshIfStale(); + expect(fetches, 4); + }, + ); + }); + + group('bundled preset files (#728)', () { + test('parse cleanly and do not clash on titles', () { + final upstream = parseUpstreamPresets( + File('assets/radio_presets/meshcore-upstream.json').readAsStringSync(), + ); + final overlay = parseOverlayPresets( + File('assets/radio_presets/offband.json').readAsStringSync(), + ); + expect(upstream.skipped, 0); + expect(overlay.skipped, 0); + expect(upstream.presets.length, 26); + expect(overlay.presets.length, 33); + final merged = mergeRadioPresets(upstream.presets, overlay.presets); + expect(merged.length, 59); + final philly = merged.firstWhere((p) => p.title == 'USA - Philly Mesh'); + expect(philly.frequencyKHz, 919500); + expect(philly.bandwidth, LoRaBandwidth.bw500); + expect(philly.pathHashMode, 1); + final euNarrow = merged.firstWhere((p) => p.title == 'EU/UK (Narrow)'); + expect(euNarrow.codingRate, LoRaCodingRate.cr4_8); + }); + }); +} diff --git a/test/widgets/radio_preset_picker_test.dart b/test/widgets/radio_preset_picker_test.dart new file mode 100644 index 00000000..a6d3caab --- /dev/null +++ b/test/widgets/radio_preset_picker_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/models/radio_settings.dart'; +import 'package:meshcore_open/services/radio_preset_service.dart'; +import 'package:meshcore_open/widgets/radio_preset_picker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +RadioPreset preset(String title, String region, RadioPresetSource source) => + RadioPreset( + id: '${source.name}:$title', + title: title, + region: region, + frequencyMHz: 910.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_5, + source: source, + ); + +/// Menu rows only exist while the menu is open; the closed button shows +/// titles from selectedItemBuilder instead. +Finder menuItems({required bool enabled}) => find.byWidgetPredicate( + (w) => w is DropdownMenuItem && w.enabled == enabled, +); + +void main() { + late RadioPresetService service; + final presets = [ + preset('Australia', 'Australia', RadioPresetSource.meshcore), + preset('USA', 'USA', RadioPresetSource.meshcore), + preset('USA - Philly Mesh', 'USA', RadioPresetSource.offband), + ]; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + service = RadioPresetService( + prefs: await SharedPreferences.getInstance(), + loadAsset: (_) async => throw StateError('no assets in this test'), + fetch: (_) async => throw StateError('offline'), + ); + }); + + Future> pump( + WidgetTester tester, { + String? selectedId, + }) async { + final picked = []; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: ListenableBuilder( + listenable: service, + builder: (context, _) => RadioPresetPicker( + service: service, + presets: presets, + selectedId: selectedId, + onSelected: picked.add, + ), + ), + ), + ), + ), + ); + return picked; + } + + Future openMenu(WidgetTester tester) async { + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + } + + testWidgets('groups presets under region headers with source labels (#729)', ( + tester, + ) async { + await pump(tester); + await openMenu(tester); + + // One non-selectable header per region, one selectable row per preset. + expect(menuItems(enabled: false), findsNWidgets(2)); + expect(menuItems(enabled: true), findsNWidgets(3)); + expect(find.text('MeshCore'), findsNWidgets(2)); + expect(find.text('Offband'), findsOneWidget); + }); + + testWidgets('selecting an entry reports it; a region header does nothing', ( + tester, + ) async { + final picked = await pump(tester); + await openMenu(tester); + + await tester.tap(menuItems(enabled: false).last, warnIfMissed: false); + await tester.pumpAndSettle(); + expect(picked, isEmpty); + + // The menu may have closed on the header tap; reopen it if so. + if (menuItems(enabled: true).evaluate().isEmpty) { + await openMenu(tester); + } + await tester.tap(find.text('USA - Philly Mesh').last); + await tester.pumpAndSettle(); + expect(picked.single.id, 'offband:USA - Philly Mesh'); + }); + + testWidgets('starts on the selected preset', (tester) async { + await pump(tester, selectedId: 'offband:USA - Philly Mesh'); + final field = tester.widget>( + find.byType(DropdownButtonFormField), + ); + expect(field.initialValue, 'offband:USA - Philly Mesh'); + }); + + testWidgets('an unknown selection shows no preset rather than failing', ( + tester, + ) async { + await pump(tester, selectedId: 'offband:gone'); + final field = tester.widget>( + find.byType(DropdownButtonFormField), + ); + expect(field.initialValue, isNull); + }); + + testWidgets('a good refresh shows no error (#748)', (tester) async { + final files = { + for (final f in [kUpstreamPresetFile, kOverlayPresetFile]) + f: File('$kRadioPresetAssetDir$f').readAsStringSync(), + }; + service = RadioPresetService( + prefs: await SharedPreferences.getInstance(), + loadAsset: (_) async => throw StateError('no assets in this test'), + fetch: (url) async => files[url.split('/').last]!, + ); + await pump(tester); + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + expect(service.refreshError, isNull); + expect(find.textContaining('Presets updated'), findsOneWidget); + expect( + find.text("Couldn't update presets. Showing the saved list."), + findsNothing, + ); + }); + + testWidgets('a failed refresh stays on screen as an error', (tester) async { + await pump(tester); + expect( + find.text('Presets included with this version of the app'), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + expect( + find.text("Couldn't update presets. Showing the saved list."), + findsOneWidget, + ); + }); +} diff --git a/untranslated.json b/untranslated.json index bb4a8869..e45cdf18 100644 --- a/untranslated.json +++ b/untranslated.json @@ -33,6 +33,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -40,6 +41,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -246,6 +254,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -253,6 +262,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -459,6 +475,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -466,6 +483,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -672,6 +696,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -679,6 +704,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -885,6 +917,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -892,6 +925,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -1098,6 +1138,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -1105,6 +1146,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -1311,6 +1359,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -1318,6 +1367,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -1524,6 +1580,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -1531,6 +1588,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -1737,6 +1801,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -1744,6 +1809,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -1950,6 +2022,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -1957,6 +2030,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -2163,6 +2243,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -2170,6 +2251,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -2376,6 +2464,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -2383,6 +2472,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -2589,6 +2685,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -2596,6 +2693,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -2802,6 +2906,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -2809,6 +2914,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -3015,6 +3127,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -3022,6 +3135,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -3228,6 +3348,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -3235,6 +3356,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock", @@ -3441,6 +3569,7 @@ "settings_aboutPlayStore", "settings_aboutDonate", "settings_aboutLinkFailed", + "settings_aboutPresetsAttribution", "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", @@ -3448,6 +3577,13 @@ "settings_infoDataScope", "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", + "settings_presetSourceMeshCore", + "settings_presetSourceOffband", + "settings_presetsRefresh", + "settings_presetsUpdating", + "settings_presetsUpdateFailed", + "settings_presetsUpdatedAt", + "settings_presetsBundled", "settings_femLna", "settings_femLnaSubtitle", "appSettings_clock",