diff --git a/lib/helpers/config_profile_writes.dart b/lib/helpers/config_profile_writes.dart new file mode 100644 index 0000000..dcbadda --- /dev/null +++ b/lib/helpers/config_profile_writes.dart @@ -0,0 +1,135 @@ +import '../models/config_profile.dart'; + +/// Enumerates the device writes a [ConfigProfile] implies (#405), device-agnostic +/// so observer / repeater / companion executors share the same rules: +/// +/// - **Skip null or empty** — a profile only touches keys it actually sets; a +/// blank never clobbers a configured value. Clearing is a separate explicit op. +/// - **Skip `jwt_token`** — it's live-minted by firmware at connect, never config. +/// - **`enabled` is written last** (the executor's activation guard) — so it is +/// returned separately from [BrokerWrites.fields]. +/// - **Danger fields** (owner/identity/credentials) are flagged so the preview +/// (#406) can gate them: broker `username`/`password`/`jwt_owner`/`jwt_email`, +/// and global `wifi.pwd`. + +/// Broker sub-keys whose set/change/wipe requires the preview's red danger gate. +const Set kDangerBrokerFields = { + ConfigKeys.brokerUsername, + ConfigKeys.brokerPassword, + ConfigKeys.brokerJwtOwner, + ConfigKeys.brokerJwtEmail, +}; + +/// Global flat keys requiring the danger gate. +const Set kDangerFlatKeys = {ConfigKeys.wifiPassword}; + +/// A single flat (non-broker) write: `wifi.*`, `mqtt.iata`, `mqtt.status_interval`. +class FlatWrite { + const FlatWrite(this.key, this.value, {this.danger = false}); + final String key; + final String value; + final bool danger; +} + +/// The writes for one broker slot. [fields] excludes `enabled` (written last by +/// the executor) and `jwt_token` (never written). [enabled] is null when the +/// profile doesn't set it, so the executor preserves the device's current state. +class BrokerWrites { + const BrokerWrites({ + required this.slot, + required this.fields, + required this.enabled, + required this.dangerFields, + }); + final int slot; + final Map fields; + final bool? enabled; + final Set dangerFields; +} + +/// The full set of writes a profile implies. +class ProfileWrites { + const ProfileWrites({required this.flats, required this.brokers}); + + /// Global flats, already ordered so `wifi.enabled` (if present) comes last. + final List flats; + final List brokers; + + bool get isEmpty => flats.isEmpty && brokers.isEmpty; + + /// True if any write (flat or broker) is a danger-gated field. + bool get hasDanger => + flats.any((f) => f.danger) || + brokers.any((b) => b.dangerFields.isNotEmpty); +} + +bool _blank(String? v) => v == null || v.isEmpty; + +ProfileWrites enumerateProfileWrites(ConfigProfile p) { + final flats = []; + + // WiFi — ssid/pwd first, enabled last (activation guard). + final wifi = p.wifi; + if (wifi != null) { + if (!_blank(wifi.ssid)) { + flats.add(FlatWrite(ConfigKeys.wifiSsid, wifi.ssid!)); + } + if (!_blank(wifi.password)) { + flats.add( + FlatWrite(ConfigKeys.wifiPassword, wifi.password!, danger: true), + ); + } + if (wifi.enabled != null) { + flats.add(FlatWrite(ConfigKeys.wifiEnabled, wifi.enabled! ? '1' : '0')); + } + } + + if (!_blank(p.regionIata)) { + flats.add(FlatWrite(ConfigKeys.mqttIata, p.regionIata!)); + } + if (p.statusInterval != null) { + flats.add(FlatWrite(ConfigKeys.mqttStatusInterval, '${p.statusInterval}')); + } + + final brokers = []; + for (final b in p.brokers) { + final fields = {}; + void put(String key, String? value) { + if (!_blank(value)) fields[key] = value!; + } + + put(ConfigKeys.brokerUrl, b.url); + if (b.port != null) fields[ConfigKeys.brokerPort] = '${b.port}'; + if (b.transport != null) { + fields[ConfigKeys.brokerTransport] = b.transport!.wire; + } + if (b.authType != null) { + fields[ConfigKeys.brokerAuthType] = b.authType!.wire; + } + put(ConfigKeys.brokerUsername, b.username); + put(ConfigKeys.brokerPassword, b.password); + // jwt_token deliberately omitted — live-minted at connect, never config. + put(ConfigKeys.brokerJwtAudience, b.jwtAudience); + if (b.jwtRefresh != null) { + fields[ConfigKeys.brokerJwtRefresh] = '${b.jwtRefresh}'; + } + put(ConfigKeys.brokerJwtOwner, b.jwtOwner); + put(ConfigKeys.brokerJwtEmail, b.jwtEmail); + put(ConfigKeys.brokerCaCert, b.caCert); + put(ConfigKeys.brokerTopicPrefix, b.topicPrefix); + put(ConfigKeys.brokerIataOverride, b.iataOverride); + + if (fields.isEmpty && b.enabled == null) continue; // nothing to write + + brokers.add( + BrokerWrites( + slot: b.slot, + fields: fields, + enabled: b.enabled, + dangerFields: fields.keys.where(kDangerBrokerFields.contains).toSet(), + ), + ); + } + + return ProfileWrites(flats: flats, brokers: brokers); +} diff --git a/lib/services/observer_apply_service.dart b/lib/services/observer_apply_service.dart new file mode 100644 index 0000000..23bd243 --- /dev/null +++ b/lib/services/observer_apply_service.dart @@ -0,0 +1,66 @@ +import '../helpers/config_profile_writes.dart'; +import 'observer_config_service.dart'; + +/// Result of applying one write (or one broker slot). [label] is safe to show — +/// it names the key/slot, never a value, so secrets never leak into UI or logs. +class ApplyItemResult { + const ApplyItemResult(this.label, this.ok, [this.error]); + final String label; + final bool ok; + final String? error; +} + +class ObserverApplyResult { + const ObserverApplyResult(this.items); + final List items; + + bool get allOk => items.every((i) => i.ok); + List get failures => items.where((i) => !i.ok).toList(); +} + +/// Applies a [ProfileWrites] plan to the connected observer (#405). +/// +/// Globals go through [ObserverConfigService.setFlat]; each broker through +/// [ObserverConfigService.saveBroker], which already disables-first, writes +/// field-at-a-time, writes `enabled` LAST, and stops at the first failure so a +/// partial save never leaves a slot live-but-corrupt (#80). We read the slot's +/// current `enabled` first so a profile that omits it preserves device state. +class ObserverApplyService { + ObserverApplyService(this._svc); + final ObserverConfigService _svc; + + Future apply(ProfileWrites writes) async { + final items = []; + + for (final f in writes.flats) { + final ok = await _svc.setFlat(f.key, f.value); + items.add(ApplyItemResult(f.key, ok, ok ? null : _svc.lastError)); + } + + for (final b in writes.brokers) { + // Current state: wasLive for the safe-save dance, and to preserve `enabled` + // when the profile doesn't set it. + final current = await _svc.getBroker(b.slot); + final wasLive = current?.enabled ?? false; + final enable = b.enabled ?? wasLive; + + final res = await _svc.saveBroker( + b.slot, + fields: b.fields, + enable: enable, + wasLive: wasLive, + ); + items.add( + res.ok + ? ApplyItemResult('broker ${b.slot}', true) + : ApplyItemResult( + 'broker ${b.slot}', + false, + 'field "${res.failedField}" failed — slot left disabled', + ), + ); + } + + return ObserverApplyResult(items); + } +} diff --git a/test/helpers/config_profile_writes_test.dart b/test/helpers/config_profile_writes_test.dart new file mode 100644 index 0000000..c4ce381 --- /dev/null +++ b/test/helpers/config_profile_writes_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/config_profile_writes.dart'; +import 'package:meshcore_open/models/config_profile.dart'; + +void main() { + group('enumerateProfileWrites', () { + test('emits only set keys; wifi.enabled ordered last', () { + final w = enumerateProfileWrites( + const ConfigProfile( + schemaVersion: 1, + wifi: WifiConfig(ssid: 'net', password: 'pw', enabled: true), + 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)), + ); + expect( + w.flats.firstWhere((f) => f.key == ConfigKeys.wifiEnabled).value, + '1', + ); + }); + + test('skips null and empty values (never clobbers)', () { + final w = enumerateProfileWrites( + const ConfigProfile( + schemaVersion: 1, + wifi: WifiConfig(ssid: '', password: null, enabled: null), + regionIata: null, + ), + ); + expect(w.isEmpty, isTrue); + }); + + test('skips jwt_token even when present', () { + final w = enumerateProfileWrites( + const ConfigProfile( + schemaVersion: 1, + brokers: [BrokerConfig(slot: 0, jwtToken: 'minted', url: 'h')], + ), + ); + final b = w.brokers.single; + expect(b.fields.containsKey(ConfigKeys.brokerJwtToken), isFalse); + expect(b.fields[ConfigKeys.brokerUrl], 'h'); + }); + + 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)], + ), + ); + final b = w.brokers.single; + expect(b.fields.containsKey(ConfigKeys.brokerEnabled), isFalse); + expect(b.enabled, true); + }); + + 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, + ), + ], + ), + ); + final f = w.brokers.single.fields; + expect(f[ConfigKeys.brokerPort], '8883'); + expect(f[ConfigKeys.brokerTransport], 'tls'); + expect(f[ConfigKeys.brokerAuthType], 'jwt'); + expect(f[ConfigKeys.brokerJwtRefresh], '3600'); + }); + + test('flags danger fields (creds/identity), not plain config', () { + final w = enumerateProfileWrites( + const ConfigProfile( + schemaVersion: 1, + wifi: WifiConfig(password: 'pw'), + brokers: [ + BrokerConfig( + slot: 0, + url: 'h', + username: 'u', + password: 'p', + jwtOwner: 'deadbeef', + jwtEmail: 'a@b.c', + jwtAudience: 'https://host', + ), + ], + ), + ); + expect(w.hasDanger, isTrue); + final b = w.brokers.single; + expect(b.dangerFields, { + ConfigKeys.brokerUsername, + ConfigKeys.brokerPassword, + 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, + ); + }); + + test('a broker with nothing set is dropped', () { + final w = enumerateProfileWrites( + const ConfigProfile(schemaVersion: 1, brokers: [BrokerConfig(slot: 3)]), + ); + expect(w.brokers, isEmpty); + }); + }); +}