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
Strycher 2 months ago
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…
Cancel
Save

Powered by TurnKey Linux.