refactor(#456): restructure config-profile schema into capability sections

Profile is now a container of capability-scoped sections (schema_version 2):
- wifi (WifiConfig) — any wifi device
- mqtt (MqttSection = region + status_interval + brokers) — observer capability
so MQTT is defined once and shared by observer / observer-repeater /
observer-companion, never redone per device. Future radio/repeater/companion/
display sections slot in alongside.

Parser reads the sectioned YAML and rejects the old flat v1 layout with a clear
message. Enumerator reads from sections but emits the SAME firmware keys, so
apply/diff/screens are unchanged. 45 config-profile tests updated + green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/463/head
Strycher 2 months ago
parent b96f20140f
commit 900c168c61

@ -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']),
);
}

@ -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 = <BrokerWrites>[];
for (final b in p.brokers) {
for (final b in (mqtt?.brokers ?? const <BrokerConfig>[])) {
final fields = <String, String>{};
void put(String key, String? value) {
if (!_blank(value)) fields[key] = value!;

@ -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<BrokerConfig> 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

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

@ -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<ConfigProfileFormatException>()),
);
});
test('requires schema_version', () {
expect(
() => parseConfigProfile('region: IAD\n'),
() => parseConfigProfile('name: x\n'),
throwsA(isA<ConfigProfileFormatException>()),
);
});
@ -80,9 +81,31 @@ brokers:
);
});
test('rejects the old flat v1 layout', () {
expect(
() => parseConfigProfile('schema_version: 1\nregion: IAD\n'),
throwsA(isA<ConfigProfileFormatException>()),
);
});
test('rejects unknown top-level key', () {
expect(
() => parseConfigProfile('schema_version: 1\nbogus: 1\n'),
() => parseConfigProfile('schema_version: 2\nbogus: 1\n'),
throwsA(isA<ConfigProfileFormatException>()),
);
});
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<ConfigProfileFormatException>()),
);
});
test('rejects unknown mqtt key', () {
expect(
() => parseConfigProfile('schema_version: 2\nmqtt:\n nope: 1\n'),
throwsA(isA<ConfigProfileFormatException>()),
);
});
@ -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<ConfigProfileFormatException>()),
);
@ -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<ConfigProfileFormatException>()),
);
});
@ -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<ConfigProfileFormatException>()),
);
@ -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<ConfigProfileFormatException>()),
);
@ -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<ConfigProfileFormatException>()),
);
});
@ -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<ConfigProfileFormatException>()),
);
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<ConfigProfileFormatException>()),
);
@ -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<ConfigProfileFormatException>()),
);
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<ConfigProfileFormatException>()),
);
@ -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) {

@ -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;

@ -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<ConfigProfile>());
expect(p.regionIata, 'IAD');
expect(p.mqtt?.regionIata, 'IAD');
});
test('fetchCatalog parses a served manifest', () async {

Loading…
Cancel
Save

Powered by TurnKey Linux.