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 <noreply@anthropic.com>pull/67/head
parent
d1884aac71
commit
1983ed94ab
@ -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 `<key> <value>` — 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 `<key>` → one [ConfigValue] frame ("key = value").
|
||||||
|
static Uint8List buildGet(String key) => _frame(opGet, key);
|
||||||
|
|
||||||
|
/// VIEW read-only selector. Firmware allowlist: `mqtt status`,
|
||||||
|
/// `mqtt view <N>`, `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.<slot>.<field>`.
|
||||||
|
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<MapEntry<String, String>> brokerFieldSets(
|
||||||
|
BrokerConfig b, {
|
||||||
|
SecretField password = const SecretField.unchanged(),
|
||||||
|
}) {
|
||||||
|
final p = 'mqtt.broker.${b.slot}';
|
||||||
|
final out = <MapEntry<String, String>>[
|
||||||
|
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<int, Map<String, String>> _slots = {};
|
||||||
|
bool _open = false;
|
||||||
|
|
||||||
|
/// Feed one response. Returns the broker list on END, else null.
|
||||||
|
/// Anything before START or after END is ignored.
|
||||||
|
List<BrokerConfig>? 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;
|
||||||
|
}
|
||||||
@ -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<String, String> 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 <BrokerConfig>[],
|
||||||
|
this.display = const DisplayConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final WifiConfig wifi;
|
||||||
|
final MqttGlobalConfig mqtt;
|
||||||
|
final List<BrokerConfig> brokers;
|
||||||
|
final DisplayConfig display;
|
||||||
|
|
||||||
|
ObserverConfig copyWith({
|
||||||
|
WifiConfig? wifi,
|
||||||
|
MqttGlobalConfig? mqtt,
|
||||||
|
List<BrokerConfig>? brokers,
|
||||||
|
DisplayConfig? display,
|
||||||
|
}) {
|
||||||
|
return ObserverConfig(
|
||||||
|
wifi: wifi ?? this.wifi,
|
||||||
|
mqtt: mqtt ?? this.mqtt,
|
||||||
|
brokers: brokers ?? this.brokers,
|
||||||
|
display: display ?? this.display,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ConfigResponse> _roundTrip(Uint8List frame) async {
|
||||||
|
final completer = Completer<ConfigResponse>();
|
||||||
|
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.<N>.<field>`). Returns true on ACK; on ERR
|
||||||
|
/// or timeout records [lastError] and returns false.
|
||||||
|
Future<bool> 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<String?> 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.<slot>.<field>`).
|
||||||
|
Future<bool> 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<List<BrokerConfig>?> getBrokers() async {
|
||||||
|
final decoder = BrokerListDecoder();
|
||||||
|
final completer = Completer<List<BrokerConfig>>();
|
||||||
|
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<void> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue