feat(#405): observer apply engine
Device-agnostic write enumerator (config_profile_writes.dart): a profile -> ordered flat writes + per-broker field maps. Skips null/empty (never clobbers), skips jwt_token (live-minted), holds enabled out for last-write, flags the danger set (username/password/jwt_owner/jwt_email + wifi.pwd) for #406's gate. Observer executor (observer_apply_service.dart): flats via setFlat, brokers via the existing saveBroker (disable-first, fields, enabled LAST, stop-on-error partial-safe #80); reads current enabled to preserve it when a profile omits it. Result labels name keys/slots only, never values (no secret leak). 8 enumerator tests. Executor is thin orchestration over the tested service; end-to-end covered by #408 hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/455/head
parent
9bc8560b65
commit
ef7629fd64
@ -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<String> kDangerBrokerFields = {
|
||||
ConfigKeys.brokerUsername,
|
||||
ConfigKeys.brokerPassword,
|
||||
ConfigKeys.brokerJwtOwner,
|
||||
ConfigKeys.brokerJwtEmail,
|
||||
};
|
||||
|
||||
/// Global flat keys requiring the danger gate.
|
||||
const Set<String> 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<String, String> fields;
|
||||
final bool? enabled;
|
||||
final Set<String> 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<FlatWrite> flats;
|
||||
final List<BrokerWrites> 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 = <FlatWrite>[];
|
||||
|
||||
// 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 = <BrokerWrites>[];
|
||||
for (final b in p.brokers) {
|
||||
final fields = <String, String>{};
|
||||
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);
|
||||
}
|
||||
@ -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<ApplyItemResult> items;
|
||||
|
||||
bool get allOk => items.every((i) => i.ok);
|
||||
List<ApplyItemResult> 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<ObserverApplyResult> apply(ProfileWrites writes) async {
|
||||
final items = <ApplyItemResult>[];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue