From ce1c5a3cb6b093c2294bf34d6eadb8e7d0b5cb2f Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 22 Jun 2026 00:33:16 -0400 Subject: [PATCH] feat(#64): observer config wire codec + models + service (logic layer) Client side of the Offband config command (CMD_OFFBAND_CONFIG 0xC0, firmware Epic F #160-#163), mirroring OffbandConfigProtocol.h + MyMesh::handleOffbandConfigCmd byte-for-byte: - models/observer_config.dart: ObserverConfig/BrokerConfig/WifiConfig/ MqttGlobalConfig/DisplayConfig, the tcp|tls|wss + none|basic|jwt enums, three-state SecretField, BrokerConfig.fromWireFields. - connector/observer_config_client.dart: pure codec -- frame builders (SET/GET/VIEW/BROKERS), parse() -> typed ConfigResponse, BrokerListDecoder (START->KV->END), supportsConfig (ver gate + 0x01 cap bit). - services/observer_config_service.dart: ChangeNotifier driving the codec over MeshCoreConnector -- send/await/parse round-trips, getBrokers stream, full-config refresh, capability gate, error/stale surfacing (SAFELANE 6). Known firmware gap flagged to RedCreek: broker enable/disable/clear have no wire path yet (configSet lacks `enabled`, VIEW allowlist blocks mutations); field SET + read work, activation/clear await a firmware F-task. Co-Authored-By: Claude Opus 4.8 --- lib/connector/observer_config_client.dart | 294 +++++++++++++++++++++ lib/models/observer_config.dart | 302 ++++++++++++++++++++++ lib/services/observer_config_service.dart | 198 ++++++++++++++ 3 files changed, 794 insertions(+) create mode 100644 lib/connector/observer_config_client.dart create mode 100644 lib/models/observer_config.dart create mode 100644 lib/services/observer_config_service.dart diff --git a/lib/connector/observer_config_client.dart b/lib/connector/observer_config_client.dart new file mode 100644 index 0000000..7238469 --- /dev/null +++ b/lib/connector/observer_config_client.dart @@ -0,0 +1,294 @@ +// Wire codec for the Offband config companion command (CMD_OFFBAND_CONFIG 0xC0). +// +// Mirrors the firmware contract in the firmware repo's +// `examples/companion_radio/OffbandConfigProtocol.h` + `MyMesh::handleOffbandConfigCmd` +// (Epic F #160-#163) byte-for-byte. This is a PURE codec: it builds request +// frames and parses response frames. It never touches BLE/USB/TCP — the +// connector sends the Uint8Lists and feeds received frames back to [parse]. +// +// Frame layout (both directions): byte[0] = code (0xC0), byte[1] = sub-type, +// byte[2..] = payload. All payloads are ASCII/UTF-8 text, NUL-terminated; +// numerics are decimal strings, transport/auth_type are the wire enum names. + +import 'dart:convert'; +import 'dart:typed_data'; + +import '../models/observer_config.dart'; + +/// One decoded Offband-config response frame. +sealed class ConfigResponse { + const ConfigResponse(); +} + +/// SET succeeded; [text] is the firmware's human confirmation line. +class ConfigAck extends ConfigResponse { + const ConfigAck(this.text); + final String text; +} + +/// Any op failed; [text] is the error message (firmware lines start "ERROR"). +class ConfigErr extends ConfigResponse { + const ConfigErr(this.text); + final String text; +} + +/// GET succeeded. [text] is the raw reply ("key = value\n"); [value] extracts +/// the value (everything after the first " = ", newline-trimmed). +class ConfigValue extends ConfigResponse { + const ConfigValue(this.text); + final String text; + + String get value => ObserverConfigClient.extractScalarValue(text); +} + +/// Start of a broker-pool dump; [count] populated slots follow. +class ConfigBrokersStart extends ConfigResponse { + const ConfigBrokersStart(this.count); + final int count; +} + +/// One broker field line: slot + key + value (already split on the first '='). +class ConfigBrokerKv extends ConfigResponse { + const ConfigBrokerKv(this.slot, this.key, this.value); + final int slot; + final String key; + final String value; +} + +/// End of a broker-pool dump. +class ConfigBrokersEnd extends ConfigResponse { + const ConfigBrokersEnd(); +} + +/// Start of a chunked VIEW text dump. +class ConfigViewStart extends ConfigResponse { + const ConfigViewStart(); +} + +/// One VIEW text chunk. +class ConfigViewChunk extends ConfigResponse { + const ConfigViewChunk(this.text); + final String text; +} + +/// End of a VIEW text dump. +class ConfigViewEnd extends ConfigResponse { + const ConfigViewEnd(); +} + +/// Frame that is not an Offband-config response (wrong code) or an +/// unrecognized sub-type. [subType] is byte[1] (or -1 if too short / wrong code). +class ConfigUnknown extends ConfigResponse { + const ConfigUnknown(this.subType); + final int subType; +} + +class ObserverConfigClient { + ObserverConfigClient._(); + + // --- Frame codes ---------------------------------------------------------- + /// Request & response frame byte[0]. + static const int cmdConfig = 0xC0; + static const int respConfig = 0xC0; + + /// Command exists iff FIRMWARE_VER_CODE >= this AND the capability bit is set. + static const int minVerCode = 14; + + /// Bit 0 of the `offband_caps` byte appended to the device-info reply. + static const int capWifiObserver = 0x01; + + // Request sub-type — byte[1]. + static const int opSet = 0x00; + static const int opGet = 0x01; + static const int opView = 0x02; + static const int opBrokers = 0x03; + + // Response sub-type — byte[1]. + static const int rAck = 0x00; + static const int rErr = 0x01; + static const int rValue = 0x02; + static const int rBrokersStart = 0x10; + static const int rBrokerKv = 0x11; + static const int rBrokersEnd = 0x12; + static const int rViewStart = 0x20; + static const int rViewChunk = 0x21; + static const int rViewEnd = 0x22; + + /// Number of MQTT broker slots (firmware OFFBAND_MAX_BROKERS). + static const int brokerSlotCount = 10; + + /// True iff the device advertises config support: version gate AND the + /// `OFFBAND_CAP_WIFI_OBSERVER` bit in the device-info `offband_caps` byte. + /// The WHOLE command (display included) is observer-gated, so both must hold. + static bool supportsConfig({ + required int firmwareVerCode, + required int offbandCaps, + }) { + return firmwareVerCode >= minVerCode && + (offbandCaps & capWifiObserver) != 0; + } + + // --- Request builders ----------------------------------------------------- + + static Uint8List _frame(int op, [String? payload]) { + final builder = BytesBuilder(); + builder.addByte(cmdConfig); + builder.addByte(op); + if (payload != null) { + builder.add(utf8.encode(payload)); + builder.addByte(0); // NUL-terminate (firmware NUL-terminates on receive) + } + return builder.toBytes(); + } + + /// SET ` ` — firmware splits on the FIRST space; [value] is the + /// verbatim remainder and may contain spaces. + static Uint8List buildSet(String key, String value) => + _frame(opSet, '$key $value'); + + /// GET `` → one [ConfigValue] frame ("key = value"). + static Uint8List buildGet(String key) => _frame(opGet, key); + + /// VIEW read-only selector. Firmware allowlist: `mqtt status`, + /// `mqtt view `, `wifi status`. Anything else is rejected. + static Uint8List buildView(String selector) => _frame(opView, selector); + + /// Paginated broker-pool dump (no payload) → START → KV* → END. + static Uint8List buildBrokers() => _frame(opBrokers); + + /// Convenience: SET one broker field, `mqtt.broker..`. + static Uint8List buildBrokerFieldSet(int slot, String field, String value) => + buildSet('mqtt.broker.$slot.$field', value); + + /// The ordered field SETs to (re)configure a broker slot's non-secret + + /// secret fields. Caller sends each, awaiting its ACK. + /// + /// NOTE: `enabled` is intentionally NOT here. The firmware + /// `handleSetBrokerField` has no `enabled` branch and the VIEW allowlist + /// blocks `mqtt enable/disable/clear`, so activation + clear have no wire + /// path in this firmware (TODO: firmware F-task — see observer_config docs). + /// The activation guard still holds: any field SET on a live slot + /// auto-disables it firmware-side. + static List> brokerFieldSets( + BrokerConfig b, { + SecretField password = const SecretField.unchanged(), + }) { + final p = 'mqtt.broker.${b.slot}'; + final out = >[ + MapEntry('$p.url', b.url), + MapEntry('$p.port', '${b.port}'), + MapEntry('$p.transport', b.transport.wire), + MapEntry('$p.auth_type', b.authType.wire), + MapEntry('$p.username', b.username), + MapEntry('$p.topic_prefix', b.topicPrefix), + MapEntry('$p.iata_override', b.iataOverride), + MapEntry('$p.jwt_audience', b.jwtAudience), + MapEntry('$p.jwt_refresh', '${b.jwtRefresh}'), + MapEntry('$p.jwt_owner', b.jwtOwner), + MapEntry('$p.jwt_email', b.jwtEmail), + MapEntry('$p.ca_cert', b.caCert), + ]; + switch (password) { + case SecretSet(:final value): + out.add(MapEntry('$p.password', value)); + case SecretClear(): + out.add(MapEntry('$p.password', '')); + case SecretUnchanged(): + break; + } + return out; + } + + // --- Response parsing ----------------------------------------------------- + + /// Decode one response frame into a typed [ConfigResponse]. + static ConfigResponse parse(Uint8List f) { + if (f.length < 2 || f[0] != respConfig) return const ConfigUnknown(-1); + switch (f[1]) { + case rAck: + return ConfigAck(_text(f, 2)); + case rErr: + return ConfigErr(_text(f, 2)); + case rValue: + return ConfigValue(_text(f, 2)); + case rBrokersStart: + return ConfigBrokersStart(f.length > 2 ? f[2] : 0); + case rBrokerKv: + if (f.length < 3) return const ConfigUnknown(rBrokerKv); + final body = _text(f, 3); + final eq = body.indexOf( + '=', + ); // split on FIRST '=' (value may contain '=') + if (eq < 0) return ConfigBrokerKv(f[2], body, ''); + return ConfigBrokerKv( + f[2], + body.substring(0, eq), + body.substring(eq + 1), + ); + case rBrokersEnd: + return const ConfigBrokersEnd(); + case rViewStart: + return const ConfigViewStart(); + case rViewChunk: + return ConfigViewChunk(_text(f, 2)); + case rViewEnd: + return const ConfigViewEnd(); + default: + return ConfigUnknown(f[1]); + } + } + + /// Extract the value from a scalar reply ("key = value\n" → "value"). + /// Falls back to the trimmed whole string if there's no " = ". + static String extractScalarValue(String reply) { + final trimmed = reply.replaceAll('\n', '').trimRight(); + final i = trimmed.indexOf(' = '); + return i < 0 ? trimmed : trimmed.substring(i + 3); + } + + /// Read a NUL-terminated UTF-8 string from [f] starting at [start]. + static String _text(Uint8List f, int start) { + if (start >= f.length) return ''; + var end = start; + while (end < f.length && f[end] != 0) { + end++; + } + return utf8.decode(f.sublist(start, end), allowMalformed: true); + } +} + +/// Accumulates a broker-pool dump (START → KV* → END) into typed +/// [BrokerConfig]s. Feed each parsed [ConfigResponse]; [add] returns the +/// decoded list once END arrives (or `[]` for an empty pool), else null. +class BrokerListDecoder { + final Map> _slots = {}; + bool _open = false; + + /// Feed one response. Returns the broker list on END, else null. + /// Anything before START or after END is ignored. + List? add(ConfigResponse r) { + switch (r) { + case ConfigBrokersStart(): + _slots.clear(); + _open = true; + return null; + case ConfigBrokerKv(:final slot, :final key, :final value): + if (_open) (_slots[slot] ??= {})[key] = value; + return null; + case ConfigBrokersEnd(): + if (!_open) return null; + _open = false; + final list = + _slots.entries + .map((e) => BrokerConfig.fromWireFields(e.key, e.value)) + .toList() + ..sort((a, b) => a.slot.compareTo(b.slot)); + return list; + default: + return null; + } + } + + /// True while a dump is in progress (START seen, END not yet). + bool get inProgress => _open; +} diff --git a/lib/models/observer_config.dart b/lib/models/observer_config.dart new file mode 100644 index 0000000..bdcbdfb --- /dev/null +++ b/lib/models/observer_config.dart @@ -0,0 +1,302 @@ +// Observer device configuration models. +// +// These mirror the firmware wire contract in the firmware repo's +// `examples/companion_radio/OffbandConfigProtocol.h` (CMD_OFFBAND_CONFIG 0xC0, +// Epic F #160-#163). The device NVS is the single source of truth: the client +// holds no local copy — it reads via ObserverConfigClient and writes +// field-at-a-time. Secrets (password / wifi.pwd) are write-only on the wire. + +/// MQTT broker transport. On the wire this is the string name +/// (`tcp` / `tls` / `wss`), never an ordinal (firmware WIRE ENCODING rule 1). +enum BrokerTransport { + tcp, + tls, + wss; + + /// The wire token the firmware CLI parses and returns. + String get wire => name; + + static BrokerTransport? fromWire(String s) { + for (final t in BrokerTransport.values) { + if (t.name == s) return t; + } + return null; + } +} + +/// MQTT broker authentication mode. Wire string is `none` / `basic` / `jwt`. +enum BrokerAuthType { + none, + basic, + jwt; + + String get wire => name; + + static BrokerAuthType? fromWire(String s) { + for (final a in BrokerAuthType.values) { + if (a.name == s) return a; + } + return null; + } +} + +/// Live WiFi STA state reported by `get wifi.status` (read-only). +enum WifiStatus { + boot, + cliRescue, + awaitingSetup, + staConnecting, + staConnected, + staFailed, + unknown; + + /// Firmware emits CamelCase tokens (`Boot`, `StaConnected`, …). + static WifiStatus fromWire(String s) { + switch (s) { + case 'Boot': + return WifiStatus.boot; + case 'CliRescue': + return WifiStatus.cliRescue; + case 'AwaitingSetup': + return WifiStatus.awaitingSetup; + case 'StaConnecting': + return WifiStatus.staConnecting; + case 'StaConnected': + return WifiStatus.staConnected; + case 'StaFailed': + return WifiStatus.staFailed; + default: + return WifiStatus.unknown; + } + } +} + +/// Write intent for a write-only secret (broker `password`, `wifi.pwd`). +/// GET never returns the value, so an editor stages one of three intents and +/// the codec emits a SET only for [SecretClear] / [SecretSet]. +sealed class SecretField { + const SecretField(); + + /// Leave the stored secret untouched — the codec sends nothing. + const factory SecretField.unchanged() = SecretUnchanged; + + /// Clear the stored secret — the codec sends an empty value. + const factory SecretField.clear() = SecretClear; + + /// Replace the stored secret — the codec sends [value]. + const factory SecretField.set(String value) = SecretSet; +} + +class SecretUnchanged extends SecretField { + const SecretUnchanged(); +} + +class SecretClear extends SecretField { + const SecretClear(); +} + +class SecretSet extends SecretField { + const SecretSet(this.value); + final String value; +} + +/// One MQTT broker slot (0-9), as read back from the `OCFG_BROKERS` dump. +/// Non-secret fields only — `password` is reported as presence ([passwordSet]), +/// `jwt_token` is never a config key. +class BrokerConfig { + const BrokerConfig({ + required this.slot, + this.enabled = false, + this.url = '', + this.port = 0, + this.transport = BrokerTransport.tcp, + this.authType = BrokerAuthType.none, + this.username = '', + this.passwordSet = false, + this.topicPrefix = '', + this.iataOverride = '', + this.jwtAudience = '', + this.jwtRefresh = 0, + this.jwtOwner = '', + this.jwtEmail = '', + this.caCert = '', + }); + + final int slot; + final bool enabled; + final String url; + final int port; + final BrokerTransport transport; + final BrokerAuthType authType; + final String username; + + /// Whether a password is stored — from the wire `(set)` / `(unset)` token. + /// The value itself is never returned. + final bool passwordSet; + final String topicPrefix; + final String iataOverride; + final String jwtAudience; + final int jwtRefresh; + final String jwtOwner; + final String jwtEmail; + final String caCert; + + /// A slot is occupied iff it has a URL (firmware `cfg.url[0] != '\0'`). + bool get isPopulated => url.isNotEmpty; + + /// Build from one slot's decoded `key=value` lines (the OCFG_BROKER_KV bodies). + /// Unknown keys are ignored; missing keys keep the defaults. + factory BrokerConfig.fromWireFields(int slot, Map kv) { + int parseInt(String? v, int fallback) => + v == null ? fallback : (int.tryParse(v.trim()) ?? fallback); + return BrokerConfig( + slot: slot, + enabled: kv['enabled'] == '1', + url: kv['url'] ?? '', + port: parseInt(kv['port'], 0), + transport: + BrokerTransport.fromWire(kv['transport'] ?? '') ?? + BrokerTransport.tcp, + authType: + BrokerAuthType.fromWire(kv['auth_type'] ?? '') ?? BrokerAuthType.none, + username: kv['username'] ?? '', + passwordSet: kv['password'] == '(set)', + topicPrefix: kv['topic_prefix'] ?? '', + iataOverride: kv['iata_override'] ?? '', + jwtAudience: kv['jwt_audience'] ?? '', + jwtRefresh: parseInt(kv['jwt_refresh'], 0), + jwtOwner: kv['jwt_owner'] ?? '', + jwtEmail: kv['jwt_email'] ?? '', + caCert: kv['ca_cert'] ?? '', + ); + } + + BrokerConfig copyWith({ + bool? enabled, + String? url, + int? port, + BrokerTransport? transport, + BrokerAuthType? authType, + String? username, + bool? passwordSet, + String? topicPrefix, + String? iataOverride, + String? jwtAudience, + int? jwtRefresh, + String? jwtOwner, + String? jwtEmail, + String? caCert, + }) { + return BrokerConfig( + slot: slot, + enabled: enabled ?? this.enabled, + url: url ?? this.url, + port: port ?? this.port, + transport: transport ?? this.transport, + authType: authType ?? this.authType, + username: username ?? this.username, + passwordSet: passwordSet ?? this.passwordSet, + topicPrefix: topicPrefix ?? this.topicPrefix, + iataOverride: iataOverride ?? this.iataOverride, + jwtAudience: jwtAudience ?? this.jwtAudience, + jwtRefresh: jwtRefresh ?? this.jwtRefresh, + jwtOwner: jwtOwner ?? this.jwtOwner, + jwtEmail: jwtEmail ?? this.jwtEmail, + caCert: caCert ?? this.caCert, + ); + } + + /// An empty (unconfigured) slot. + factory BrokerConfig.empty(int slot) => BrokerConfig(slot: slot); +} + +/// WiFi settings. `wifi.pwd` is write-only (no read state); [status]/[ip] come +/// from the read-only `wifi.status`. +class WifiConfig { + const WifiConfig({ + this.ssid = '', + this.enabled = true, + this.status = WifiStatus.unknown, + this.ip, + }); + + final String ssid; + final bool enabled; + final WifiStatus status; + final String? ip; + + WifiConfig copyWith({ + String? ssid, + bool? enabled, + WifiStatus? status, + String? ip, + }) { + return WifiConfig( + ssid: ssid ?? this.ssid, + enabled: enabled ?? this.enabled, + status: status ?? this.status, + ip: ip ?? this.ip, + ); + } +} + +/// Pool-wide MQTT settings (`mqtt.iata`, `mqtt.status_interval`). +class MqttGlobalConfig { + const MqttGlobalConfig({this.iata = '', this.statusInterval = 60}); + + final String iata; + + /// Status publish interval in seconds (firmware range 10..3600). + final int statusInterval; + + MqttGlobalConfig copyWith({String? iata, int? statusInterval}) { + return MqttGlobalConfig( + iata: iata ?? this.iata, + statusInterval: statusInterval ?? this.statusInterval, + ); + } +} + +/// Display settings (`display.always_on`, `display.rotation` 0|180). +class DisplayConfig { + const DisplayConfig({this.alwaysOn = false, this.rotation = 0}); + + final bool alwaysOn; + final int rotation; // 0 or 180 + + DisplayConfig copyWith({bool? alwaysOn, int? rotation}) { + return DisplayConfig( + alwaysOn: alwaysOn ?? this.alwaysOn, + rotation: rotation ?? this.rotation, + ); + } +} + +/// Full observer configuration snapshot read from the device. +class ObserverConfig { + const ObserverConfig({ + this.wifi = const WifiConfig(), + this.mqtt = const MqttGlobalConfig(), + this.brokers = const [], + this.display = const DisplayConfig(), + }); + + final WifiConfig wifi; + final MqttGlobalConfig mqtt; + final List brokers; + final DisplayConfig display; + + ObserverConfig copyWith({ + WifiConfig? wifi, + MqttGlobalConfig? mqtt, + List? brokers, + DisplayConfig? display, + }) { + return ObserverConfig( + wifi: wifi ?? this.wifi, + mqtt: mqtt ?? this.mqtt, + brokers: brokers ?? this.brokers, + display: display ?? this.display, + ); + } +} diff --git a/lib/services/observer_config_service.dart b/lib/services/observer_config_service.dart new file mode 100644 index 0000000..e538169 --- /dev/null +++ b/lib/services/observer_config_service.dart @@ -0,0 +1,198 @@ +// Drives the Offband config command end-to-end: builds frames via +// [ObserverConfigClient], sends them over the active transport, awaits and +// parses the response, and holds the parsed [ObserverConfig]. +// +// Device NVS is the source of truth — this service caches the last read but +// never persists locally. The firmware protocol has no request id, so +// responses are correlated by ORDER: keep ONE request in flight at a time +// (the settings UI sends staged changes sequentially, awaiting each). + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../connector/meshcore_connector.dart'; +import '../connector/observer_config_client.dart'; +import '../models/observer_config.dart'; + +class ObserverConfigService extends ChangeNotifier { + ObserverConfigService(this._connector); + + final MeshCoreConnector _connector; + + /// Per-request timeout. The device replies within a few frames; a miss means + /// an unsupported node or a dropped frame. + Duration timeout = const Duration(seconds: 6); + + ObserverConfig? _config; + ObserverConfig? get config => _config; + + /// True after a refresh failed to complete — the UI shows stale data warning + /// rather than silently presenting an out-of-date snapshot. + bool _stale = false; + bool get stale => _stale; + + /// Last error surfaced (SAFELANE §6 — every failure is visible). The UI shows + /// it; cleared on the next success. + String? _lastError; + String? get lastError => _lastError; + + /// Whether the connected device supports the config command (ver gate AND the + /// capability bit). Wired from the connector's device-info parse via + /// [updateCapability]; the Observer settings category is shown only when true. + bool _supported = false; + bool get supported => _supported; + + /// Called by the connector when it parses the device-info reply (the + /// `offband_caps` byte appended after `path_hash_mode`, plus the version code). + void updateCapability({ + required int firmwareVerCode, + required int offbandCaps, + }) { + final next = ObserverConfigClient.supportsConfig( + firmwareVerCode: firmwareVerCode, + offbandCaps: offbandCaps, + ); + if (next != _supported) { + _supported = next; + notifyListeners(); + } + } + + void _setError(String? e) { + _lastError = e; + notifyListeners(); + } + + // --- Round-trips ---------------------------------------------------------- + + /// Send [frame] and await the first config response (scalar SET/GET). + Future _roundTrip(Uint8List frame) async { + final completer = Completer(); + final sub = _connector.receivedFrames.listen((f) { + if (f.isNotEmpty && + f[0] == ObserverConfigClient.respConfig && + !completer.isCompleted) { + completer.complete(ObserverConfigClient.parse(f)); + } + }); + try { + await _connector.sendFrame(frame); + return await completer.future.timeout(timeout); + } finally { + await sub.cancel(); + } + } + + /// SET a flat key (or `mqtt.broker..`). Returns true on ACK; on ERR + /// or timeout records [lastError] and returns false. + Future setFlat(String key, String value) async { + try { + final r = await _roundTrip(ObserverConfigClient.buildSet(key, value)); + if (r is ConfigAck) { + _setError(null); + return true; + } + _setError(r is ConfigErr ? r.text : 'SET $key: unexpected response'); + return false; + } catch (e) { + _setError('SET $key failed: $e'); + return false; + } + } + + /// GET a flat key. Returns the value text, or null on ERR/timeout. + Future getFlat(String key) async { + try { + final r = await _roundTrip(ObserverConfigClient.buildGet(key)); + if (r is ConfigValue) return r.value; + if (r is ConfigErr) _setError(r.text); + return null; + } catch (e) { + _setError('GET $key failed: $e'); + return null; + } + } + + /// SET one broker field (`mqtt.broker..`). + Future setBrokerField(int slot, String field, String value) => + setFlat('mqtt.broker.$slot.$field', value); + + /// Read the broker pool (paginated START → KV → END). Null on timeout. + Future?> getBrokers() async { + final decoder = BrokerListDecoder(); + final completer = Completer>(); + final sub = _connector.receivedFrames.listen((f) { + if (f.isEmpty || f[0] != ObserverConfigClient.respConfig) return; + final list = decoder.add(ObserverConfigClient.parse(f)); + if (list != null && !completer.isCompleted) completer.complete(list); + }); + try { + await _connector.sendFrame(ObserverConfigClient.buildBrokers()); + return await completer.future.timeout(timeout); + } catch (e) { + _setError('broker list failed: $e'); + return null; + } finally { + await sub.cancel(); + } + } + + // --- Full snapshot -------------------------------------------------------- + + /// Read the whole observer config from the device into [config]. On any + /// failure sets [stale] so the UI never presents a half-read snapshot as live. + Future refresh() async { + try { + final ssid = await getFlat('wifi.ssid'); + final wifiEnabled = await getFlat('wifi.enabled'); + final wifiStatus = await getFlat('wifi.status'); + final iata = await getFlat('mqtt.iata'); + final statusInterval = await getFlat('mqtt.status_interval'); + final alwaysOn = await getFlat('display.always_on'); + final rotation = await getFlat('display.rotation'); + final brokers = await getBrokers(); + + if (brokers == null) { + _stale = true; + notifyListeners(); + return; + } + + _config = ObserverConfig( + wifi: _parseWifi(ssid, wifiEnabled, wifiStatus), + mqtt: MqttGlobalConfig( + iata: iata ?? '', + statusInterval: int.tryParse(statusInterval ?? '') ?? 60, + ), + brokers: brokers, + display: DisplayConfig( + alwaysOn: alwaysOn == '1', + rotation: int.tryParse(rotation ?? '') ?? 0, + ), + ); + _stale = false; + _lastError = null; + notifyListeners(); + } catch (e) { + _stale = true; + _setError('refresh failed: $e'); + } + } + + WifiConfig _parseWifi(String? ssid, String? enabled, String? statusRaw) { + var status = WifiStatus.unknown; + String? ip; + if (statusRaw != null && statusRaw.isNotEmpty) { + final parts = statusRaw.split(' ip='); + status = WifiStatus.fromWire(parts.first.trim()); + if (parts.length > 1) ip = parts[1].trim(); + } + return WifiConfig( + ssid: (ssid == null || ssid == '(unset)') ? '' : ssid, + enabled: enabled != '0', + status: status, + ip: ip, + ); + } +}