diff --git a/lib/helpers/config_profile_parser.dart b/lib/helpers/config_profile_parser.dart index a84b93f..8ce7fda 100644 --- a/lib/helpers/config_profile_parser.dart +++ b/lib/helpers/config_profile_parser.dart @@ -20,22 +20,23 @@ class ConfigProfileFormatException implements Exception { /// skip. Only keys present in the document appear in the model; everything else /// stays null so the apply engines touch only what the profile sets. /// -/// Expected shape: +/// Expected shape (v2 — capability sections, #456): /// ```yaml -/// schema_version: 1 -/// name: "US wide-area" # optional label, not applied +/// schema_version: 2 +/// name: "US wide-area" # optional label, not applied /// wifi: { ssid: "...", password: "...", enabled: true } -/// region: "IAD" # -> mqtt.iata -/// status_interval: 60 -/// brokers: -/// - slot: 0 -/// enabled: true -/// url: "..." -/// port: 8883 -/// transport: tls # tcp | tls | wss -/// auth_type: basic # none | basic | jwt -/// username: "..." -/// ... +/// mqtt: +/// region: "IAD" # -> mqtt.iata +/// status_interval: 60 +/// brokers: +/// - slot: 0 +/// enabled: true +/// url: "..." +/// port: 8883 +/// transport: tls # tcp | tls | wss +/// auth_type: basic # none | basic | jwt +/// username: "..." +/// ... /// ``` ConfigProfile parseConfigProfile(String source) { final dynamic doc; @@ -55,22 +56,36 @@ ConfigProfile parseConfigProfile(String source) { '($kConfigProfileSchemaVersion). Update the app.', ); } + if (version < 2) { + throw ConfigProfileFormatException( + 'Profile schema_version $version uses the old flat layout. Re-export it ' + 'in the sectioned v2 format (wifi:/mqtt:).', + ); + } _rejectUnknownKeys(map, const { 'schema_version', 'name', 'wifi', - 'region', - 'status_interval', - 'brokers', + 'mqtt', }, root); return ConfigProfile( schemaVersion: version, name: _optString(map, 'name', root), wifi: _parseWifi(map['wifi']), - regionIata: _optString(map, 'region', root), - statusInterval: _optUint(map, 'status_interval', root), + mqtt: _parseMqtt(map['mqtt']), + ); +} + +MqttSection? _parseMqtt(dynamic node) { + if (node == null) return null; + const ctx = 'mqtt'; + final map = _asMap(node, ctx); + _rejectUnknownKeys(map, const {'region', 'status_interval', 'brokers'}, ctx); + return MqttSection( + regionIata: _optString(map, 'region', ctx), + statusInterval: _optUint(map, 'status_interval', ctx), brokers: _parseBrokers(map['brokers']), ); } diff --git a/lib/helpers/config_profile_writes.dart b/lib/helpers/config_profile_writes.dart index 67a66ee..176cedd 100644 --- a/lib/helpers/config_profile_writes.dart +++ b/lib/helpers/config_profile_writes.dart @@ -133,15 +133,18 @@ ProfileWrites enumerateProfileWrites(ConfigProfile p) { } } - if (!_blank(p.regionIata)) { - flats.add(FlatWrite(ConfigKeys.mqttIata, p.regionIata!)); + final mqtt = p.mqtt; + if (mqtt != null && !_blank(mqtt.regionIata)) { + flats.add(FlatWrite(ConfigKeys.mqttIata, mqtt.regionIata!)); } - if (p.statusInterval != null) { - flats.add(FlatWrite(ConfigKeys.mqttStatusInterval, '${p.statusInterval}')); + if (mqtt != null && mqtt.statusInterval != null) { + flats.add( + FlatWrite(ConfigKeys.mqttStatusInterval, '${mqtt.statusInterval}'), + ); } final brokers = []; - for (final b in p.brokers) { + for (final b in (mqtt?.brokers ?? const [])) { final fields = {}; void put(String key, String? value) { if (!_blank(value)) fields[key] = value!; diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart index 94b13ce..51ee47e 100644 --- a/lib/models/config_profile.dart +++ b/lib/models/config_profile.dart @@ -15,7 +15,8 @@ library; /// Bumped when the on-disk/YAML shape changes incompatibly. The parser (#403) /// rejects a profile whose declared version it does not understand. -const int kConfigProfileSchemaVersion = 1; +/// v2 (#456): capability sections (`wifi`, `mqtt`, …). v1 was the flat layout. +const int kConfigProfileSchemaVersion = 2; /// Number of broker slots the firmware exposes (`mqtt_b0`..`mqtt_b5`). const int kMaxBrokerSlots = 6; @@ -109,31 +110,51 @@ class BrokerConfig { final String? iataOverride; } -/// A complete importable config profile. -class ConfigProfile { - const ConfigProfile({ - required this.schemaVersion, - this.wifi, +/// The `mqtt` section — observer/MQTT capability. Shared by any device running +/// the observer role (observer, observer-repeater, observer-companion); never a +/// plain companion or plain repeater (#456). +class MqttSection { + const MqttSection({ this.regionIata, this.statusInterval, this.brokers = const [], - this.name, }); - final int schemaVersion; - final WifiConfig? wifi; - - /// `mqtt.iata`, the region/IATA code applied globally. + /// `mqtt.iata`, the region/IATA code. final String? regionIata; /// `mqtt.status_interval`, seconds between status publishes. final int? statusInterval; - /// Populated broker slots only (may be sparse; each carries its [BrokerConfig.slot]). + /// Populated broker slots only (sparse; each carries its [BrokerConfig.slot]). final List brokers; - /// Optional human label for the profile (not applied to the device). + bool get isEmpty => + regionIata == null && statusInterval == null && brokers.isEmpty; +} + +/// A complete importable config profile — a set of capability-scoped sections +/// (#456). A device applies the sections it supports; the apply *mechanism* is +/// per-device, the section *schema* is per-capability and shared. Future +/// sections (`radio`, `repeater`, `companion`, `display`) slot in alongside. +class ConfigProfile { + const ConfigProfile({ + required this.schemaVersion, + this.name, + this.wifi, + this.mqtt, + }); + + final int schemaVersion; + + /// Optional human label (not applied to the device). final String? name; + + /// `wifi` section — any wifi-capable device. + final WifiConfig? wifi; + + /// `mqtt` section — observer/MQTT capability. + final MqttSection? mqtt; } /// Firmware config-key names. Callers (parser #403, apply engines) build keys diff --git a/test/helpers/config_profile_diff_test.dart b/test/helpers/config_profile_diff_test.dart index 32dfd1c..07aba57 100644 --- a/test/helpers/config_profile_diff_test.dart +++ b/test/helpers/config_profile_diff_test.dart @@ -10,9 +10,8 @@ void main() { test('categorizes add vs change and drops unchanged', () { final w = _writes( const ConfigProfile( - schemaVersion: 1, - regionIata: 'IAD', - statusInterval: 60, + schemaVersion: 2, + mqtt: MqttSection(regionIata: 'IAD', statusInterval: 60), ), ); final d = buildProfileDiff( @@ -32,7 +31,10 @@ void main() { test('add when device has no current value', () { final w = _writes( - const ConfigProfile(schemaVersion: 1, regionIata: 'IAD'), + const ConfigProfile( + schemaVersion: 2, + mqtt: MqttSection(regionIata: 'IAD'), + ), ); final d = buildProfileDiff( w, @@ -46,11 +48,13 @@ void main() { test('flags danger + secret rows', () { final w = _writes( const ConfigProfile( - schemaVersion: 1, + schemaVersion: 2, wifi: WifiConfig(password: 'pw'), - brokers: [ - BrokerConfig(slot: 0, username: 'u', password: 'p', url: 'h'), - ], + mqtt: MqttSection( + brokers: [ + BrokerConfig(slot: 0, username: 'u', password: 'p', url: 'h'), + ], + ), ), ); final d = buildProfileDiff( @@ -76,8 +80,10 @@ void main() { test('broker enabled change is a plain (non-danger) row', () { final w = _writes( const ConfigProfile( - schemaVersion: 1, - brokers: [BrokerConfig(slot: 2, enabled: true, url: 'h')], + schemaVersion: 2, + mqtt: MqttSection( + brokers: [BrokerConfig(slot: 2, enabled: true, url: 'h')], + ), ), ); final d = buildProfileDiff( diff --git a/test/helpers/config_profile_parser_test.dart b/test/helpers/config_profile_parser_test.dart index 4e8b3ff..b8df6a9 100644 --- a/test/helpers/config_profile_parser_test.dart +++ b/test/helpers/config_profile_parser_test.dart @@ -4,71 +4,72 @@ import 'package:meshcore_open/models/config_profile.dart'; void main() { group('parseConfigProfile', () { - test('parses a full profile', () { + test('parses a full sectioned profile', () { final p = parseConfigProfile(''' -schema_version: 1 +schema_version: 2 name: US wide-area wifi: ssid: MyNet password: secret enabled: true -region: IAD -status_interval: 60 -brokers: - - slot: 0 - enabled: true - url: mqtt.example.org - port: 8883 - transport: tls - auth_type: basic - username: u - password: p - topic_prefix: meshcore - - slot: 2 - enabled: false - transport: wss - auth_type: jwt - jwt_refresh: 3600 +mqtt: + region: IAD + status_interval: 60 + brokers: + - slot: 0 + enabled: true + url: mqtt.example.org + port: 8883 + transport: tls + auth_type: basic + username: u + password: p + topic_prefix: meshcore + - slot: 2 + enabled: false + transport: wss + auth_type: jwt + jwt_refresh: 3600 '''); - expect(p.schemaVersion, 1); + expect(p.schemaVersion, 2); expect(p.name, 'US wide-area'); expect(p.wifi?.ssid, 'MyNet'); expect(p.wifi?.enabled, true); - expect(p.regionIata, 'IAD'); - expect(p.statusInterval, 60); - expect(p.brokers.length, 2); + expect(p.mqtt?.regionIata, 'IAD'); + expect(p.mqtt?.statusInterval, 60); + expect(p.mqtt?.brokers.length, 2); - final b0 = p.brokers.firstWhere((b) => b.slot == 0); + final b0 = p.mqtt!.brokers.firstWhere((b) => b.slot == 0); expect(b0.url, 'mqtt.example.org'); expect(b0.port, 8883); expect(b0.transport, MqttTransport.tls); expect(b0.authType, MqttAuthType.basic); - final b2 = p.brokers.firstWhere((b) => b.slot == 2); + final b2 = p.mqtt!.brokers.firstWhere((b) => b.slot == 2); expect(b2.enabled, false); expect(b2.transport, MqttTransport.wss); expect(b2.authType, MqttAuthType.jwt); expect(b2.jwtRefresh, 3600); }); - test('leaves unset keys null (partial profile)', () { - final p = parseConfigProfile('schema_version: 1\nregion: LHR\n'); - expect(p.regionIata, 'LHR'); + test('leaves unset sections null (partial profile)', () { + final p = parseConfigProfile('schema_version: 2\nmqtt:\n region: LHR\n'); + expect(p.mqtt?.regionIata, 'LHR'); expect(p.wifi, isNull); - expect(p.statusInterval, isNull); - expect(p.brokers, isEmpty); + expect(p.mqtt?.statusInterval, isNull); + expect(p.mqtt?.brokers, isEmpty); }); test('rejects invalid YAML', () { expect( - () => parseConfigProfile('schema_version: 1\n : bad'), + () => parseConfigProfile('schema_version: 2\n : bad'), throwsA(isA()), ); }); test('requires schema_version', () { expect( - () => parseConfigProfile('region: IAD\n'), + () => parseConfigProfile('name: x\n'), throwsA(isA()), ); }); @@ -80,9 +81,31 @@ brokers: ); }); + test('rejects the old flat v1 layout', () { + expect( + () => parseConfigProfile('schema_version: 1\nregion: IAD\n'), + throwsA(isA()), + ); + }); + test('rejects unknown top-level key', () { expect( - () => parseConfigProfile('schema_version: 1\nbogus: 1\n'), + () => parseConfigProfile('schema_version: 2\nbogus: 1\n'), + throwsA(isA()), + ); + }); + + test('rejects a top-level key that belongs in a section', () { + // region moved under mqtt in v2; at top level it is now unknown. + expect( + () => parseConfigProfile('schema_version: 2\nregion: IAD\n'), + throwsA(isA()), + ); + }); + + test('rejects unknown mqtt key', () { + expect( + () => parseConfigProfile('schema_version: 2\nmqtt:\n nope: 1\n'), throwsA(isA()), ); }); @@ -90,7 +113,7 @@ brokers: test('rejects unknown broker key', () { expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n nope: 1\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n nope: 1\n', ), throwsA(isA()), ); @@ -98,7 +121,9 @@ brokers: test('rejects an out-of-range broker slot', () { expect( - () => parseConfigProfile('schema_version: 1\nbrokers:\n - slot: 9\n'), + () => parseConfigProfile( + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 9\n', + ), throwsA(isA()), ); }); @@ -106,7 +131,7 @@ brokers: test('rejects a duplicate broker slot', () { expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n - slot: 0\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n - slot: 0\n', ), throwsA(isA()), ); @@ -115,7 +140,7 @@ brokers: test('rejects an unknown transport value', () { expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n transport: carrier-pigeon\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n transport: carrier-pigeon\n', ), throwsA(isA()), ); @@ -123,7 +148,9 @@ brokers: test('rejects a wrong type', () { expect( - () => parseConfigProfile('schema_version: 1\nstatus_interval: soon\n'), + () => parseConfigProfile( + 'schema_version: 2\nmqtt:\n status_interval: soon\n', + ), throwsA(isA()), ); }); @@ -131,13 +158,13 @@ brokers: test('rejects an out-of-range broker port', () { expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n port: 70000\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n port: 70000\n', ), throwsA(isA()), ); expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n port: 0\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n port: 0\n', ), throwsA(isA()), ); @@ -145,12 +172,14 @@ brokers: test('rejects negative integer fields', () { expect( - () => parseConfigProfile('schema_version: 1\nstatus_interval: -60\n'), + () => parseConfigProfile( + 'schema_version: 2\nmqtt:\n status_interval: -60\n', + ), throwsA(isA()), ); expect( () => parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n jwt_refresh: -1\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n jwt_refresh: -1\n', ), throwsA(isA()), ); @@ -159,7 +188,7 @@ brokers: test('nested type errors name the broker section', () { try { parseConfigProfile( - 'schema_version: 1\nbrokers:\n - slot: 0\n port: "x"\n', + 'schema_version: 2\nmqtt:\n brokers:\n - slot: 0\n port: "x"\n', ); fail('expected throw'); } on ConfigProfileFormatException catch (e) { diff --git a/test/helpers/config_profile_writes_test.dart b/test/helpers/config_profile_writes_test.dart index 04703d8..8753450 100644 --- a/test/helpers/config_profile_writes_test.dart +++ b/test/helpers/config_profile_writes_test.dart @@ -7,16 +7,14 @@ void main() { test('emits only set keys; wifi.enabled ordered last', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, + schemaVersion: 2, wifi: WifiConfig(ssid: 'net', password: 'pw', enabled: true), - regionIata: 'IAD', - statusInterval: 60, + mqtt: MqttSection(regionIata: 'IAD', statusInterval: 60), ), ); final keys = w.flats.map((f) => f.key).toList(); expect(keys, contains(ConfigKeys.wifiSsid)); expect(keys, contains(ConfigKeys.mqttIata)); - // wifi.enabled must come after wifi.ssid/pwd expect( keys.indexOf(ConfigKeys.wifiEnabled), greaterThan(keys.indexOf(ConfigKeys.wifiSsid)), @@ -30,9 +28,8 @@ void main() { test('skips null and empty values (never clobbers)', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, + schemaVersion: 2, wifi: WifiConfig(ssid: '', password: null, enabled: null), - regionIata: null, ), ); expect(w.isEmpty, isTrue); @@ -41,8 +38,10 @@ void main() { test('skips jwt_token even when present', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, - brokers: [BrokerConfig(slot: 0, jwtToken: 'minted', url: 'h')], + schemaVersion: 2, + mqtt: MqttSection( + brokers: [BrokerConfig(slot: 0, jwtToken: 'minted', url: 'h')], + ), ), ); final b = w.brokers.single; @@ -53,8 +52,10 @@ void main() { test('enabled kept out of fields (executor writes it last)', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, - brokers: [BrokerConfig(slot: 1, url: 'h', enabled: true)], + schemaVersion: 2, + mqtt: MqttSection( + brokers: [BrokerConfig(slot: 1, url: 'h', enabled: true)], + ), ), ); final b = w.brokers.single; @@ -65,16 +66,18 @@ void main() { test('formats enums as wire strings and ints as text', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, - brokers: [ - BrokerConfig( - slot: 0, - port: 8883, - transport: MqttTransport.tls, - authType: MqttAuthType.jwt, - jwtRefresh: 3600, - ), - ], + schemaVersion: 2, + mqtt: MqttSection( + brokers: [ + BrokerConfig( + slot: 0, + port: 8883, + transport: MqttTransport.tls, + authType: MqttAuthType.jwt, + jwtRefresh: 3600, + ), + ], + ), ), ); final f = w.brokers.single.fields; @@ -87,19 +90,21 @@ void main() { test('flags danger fields (creds/identity), not plain config', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, + schemaVersion: 2, wifi: WifiConfig(password: 'pw'), - brokers: [ - BrokerConfig( - slot: 0, - url: 'h', - username: 'u', - password: 'p', - jwtOwner: 'deadbeef', - jwtEmail: 'a@b.c', - jwtAudience: 'https://host', - ), - ], + mqtt: MqttSection( + brokers: [ + BrokerConfig( + slot: 0, + url: 'h', + username: 'u', + password: 'p', + jwtOwner: 'deadbeef', + jwtEmail: 'a@b.c', + jwtAudience: 'https://host', + ), + ], + ), ), ); expect(w.hasDanger, isTrue); @@ -110,10 +115,8 @@ void main() { ConfigKeys.brokerJwtOwner, ConfigKeys.brokerJwtEmail, }); - // audience + url are not danger expect(b.dangerFields.contains(ConfigKeys.brokerJwtAudience), isFalse); expect(b.dangerFields.contains(ConfigKeys.brokerUrl), isFalse); - // wifi.pwd is a danger flat expect( w.flats.firstWhere((f) => f.key == ConfigKeys.wifiPassword).danger, isTrue, @@ -122,7 +125,10 @@ void main() { test('a broker with nothing set is dropped', () { final w = enumerateProfileWrites( - const ConfigProfile(schemaVersion: 1, brokers: [BrokerConfig(slot: 3)]), + const ConfigProfile( + schemaVersion: 2, + mqtt: MqttSection(brokers: [BrokerConfig(slot: 3)]), + ), ); expect(w.brokers, isEmpty); }); @@ -132,22 +138,23 @@ void main() { test('splits a mixed broker; enabled rides with safe half only', () { final w = enumerateProfileWrites( const ConfigProfile( - schemaVersion: 1, + schemaVersion: 2, wifi: WifiConfig(ssid: 'net', password: 'pw', enabled: true), - brokers: [ - BrokerConfig( - slot: 0, - url: 'h', - username: 'u', - password: 'p', - enabled: true, - ), - ], + mqtt: MqttSection( + brokers: [ + BrokerConfig( + slot: 0, + url: 'h', + username: 'u', + password: 'p', + enabled: true, + ), + ], + ), ), ); final s = splitProfileWrites(w); - // safe: wifi.ssid + wifi.enabled flats; broker url + enabled final safeFlatKeys = s.safe.flats.map((f) => f.key).toSet(); expect(safeFlatKeys, contains(ConfigKeys.wifiSsid)); expect(safeFlatKeys, contains(ConfigKeys.wifiEnabled)); @@ -155,7 +162,6 @@ void main() { expect(s.safe.brokers.single.fields.keys, contains(ConfigKeys.brokerUrl)); expect(s.safe.brokers.single.enabled, true); - // danger: wifi.pwd flat; broker username + password, enabled NOT toggled final dangerFlatKeys = s.danger.flats.map((f) => f.key).toSet(); expect(dangerFlatKeys, {ConfigKeys.wifiPassword}); final db = s.danger.brokers.single; diff --git a/test/services/config_source_service_test.dart b/test/services/config_source_service_test.dart index 498b231..71fac5f 100644 --- a/test/services/config_source_service_test.dart +++ b/test/services/config_source_service_test.dart @@ -50,12 +50,15 @@ void main() { test('fetchProfile parses a served YAML', () async { final svc = ConfigSourceService( client: MockClient((req) async { - return http.Response('schema_version: 1\nregion: IAD\n', 200); + return http.Response( + 'schema_version: 2\nmqtt:\n region: IAD\n', + 200, + ); }), ); final p = await svc.fetchProfile('https://h/p.yaml'); expect(p, isA()); - expect(p.regionIata, 'IAD'); + expect(p.mqtt?.regionIata, 'IAD'); }); test('fetchCatalog parses a served manifest', () async {