You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/models/observer_config.dart

468 lines
14 KiB

// 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;
}
/// Outcome of verifying that a broker enable/disable actually took effect, by
/// re-reading the slot after the SET (#89). A firmware build can ACK a toggle as
/// success without applying it (meshcore-firmware#179), so the UI never trusts
/// the ACK alone — it re-reads and reports what the device actually says.
enum BrokerApplyOutcome {
/// Re-read confirms the device is in the intended enabled state.
applied,
/// The device ACKed success but the re-read shows the unchanged state — it did
/// not apply the toggle. Surfaced as a warning, never a false success.
notApplied,
/// The re-read itself failed (timeout/disconnect — e.g. an enable that rebooted
/// the device), so the result can't be confirmed either way.
unverified,
}
/// Live runtime state of a broker connection, from the additive `state` field in
/// the OCFG_BROKERS dump (firmware #172). `unknown` = field absent (older
/// firmware) ⇒ the UI falls back to plain enabled/disabled.
enum BrokerRuntimeState {
down,
connecting,
up,
backoff,
heldNoClock,
heldNoHeap,
unknown;
static BrokerRuntimeState fromWire(String? s) {
switch (s) {
case 'down':
return BrokerRuntimeState.down;
case 'connecting':
return BrokerRuntimeState.connecting;
case 'up':
return BrokerRuntimeState.up;
case 'backoff':
return BrokerRuntimeState.backoff;
case 'held_no_clock':
return BrokerRuntimeState.heldNoClock;
case 'held_no_heap':
return BrokerRuntimeState.heldNoHeap;
default:
return BrokerRuntimeState.unknown;
}
}
}
/// Last connection error class, from the additive `last_error` field (firmware
/// #172). Qualifies a [BrokerRuntimeState.backoff] failure.
enum BrokerLastError {
none,
tcp,
auth,
tls,
other;
static BrokerLastError fromWire(String? s) {
switch (s) {
case 'tcp':
return BrokerLastError.tcp;
case 'auth':
return BrokerLastError.auth;
case 'tls':
return BrokerLastError.tls;
case 'other':
return BrokerLastError.other;
default:
return BrokerLastError.none;
}
}
}
/// Display category for a broker's status line — drives the label colour/icon.
enum BrokerStatusKind { connected, connecting, failed, held, idle, disabled }
/// A broker's human-facing status: a [label] plus a [kind] the UI colours.
class BrokerStatus {
const BrokerStatus(this.label, this.kind);
final String label;
final BrokerStatusKind kind;
}
/// 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 = '',
this.runtimeState = BrokerRuntimeState.unknown,
this.lastError = BrokerLastError.none,
this.jwtOwnerResolved = '',
this.iataResolved = '',
});
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;
/// Live runtime state (firmware #172); [BrokerRuntimeState.unknown] when the
/// device doesn't report it (older firmware).
final BrokerRuntimeState runtimeState;
/// Last connection error class, qualifying a [BrokerRuntimeState.backoff] (#172).
final BrokerLastError lastError;
/// Resolved defaults shown as greyed hints when the raw key is blank (#173).
/// Never written back — the blank raw key stays the source of truth.
final String jwtOwnerResolved;
final String iataResolved;
/// A slot is occupied iff it has a URL (firmware `cfg.url[0] != '\0'`).
bool get isPopulated => url.isNotEmpty;
/// Why this broker can't be safely enabled, or null if complete. Shared by
/// the editor's pre-save check and the list's quick Enable so the client
/// never activates a broker that can't work (#80).
String? get enableError {
if (url.isEmpty) return 'URL is required';
if (port < 1 || port > 65535) return 'Port must be between 1 and 65535';
// JWT owner (device pubkey), IATA override, etc. are firmware-defaulted, so
// the client does NOT gate them — the firmware enforces with its defaults.
return null;
}
/// Classify whether a toggle/save took, comparing the [intendedEnabled] state
/// to a re-read [actualEnabled] (null = the re-read failed). Pure and
/// device-agnostic: it reports only what the device reported back, never
/// assuming a change took that the device didn't confirm (#89).
static BrokerApplyOutcome classifyApply({
required bool intendedEnabled,
required bool? actualEnabled,
}) {
if (actualEnabled == null) return BrokerApplyOutcome.unverified;
return actualEnabled == intendedEnabled
? BrokerApplyOutcome.applied
: BrokerApplyOutcome.notApplied;
}
/// Human-facing status, derived from [enabled] + [runtimeState] + [lastError]
/// (#172). When the device doesn't report runtime state ([runtimeState] is
/// [BrokerRuntimeState.unknown], e.g. older firmware) this is the plain
/// enabled/disabled the UI showed before — so absent fields read as today.
BrokerStatus get status {
if (!enabled) {
return const BrokerStatus('Disabled', BrokerStatusKind.disabled);
}
switch (runtimeState) {
case BrokerRuntimeState.up:
return const BrokerStatus('Connected', BrokerStatusKind.connected);
case BrokerRuntimeState.connecting:
return const BrokerStatus('Connecting…', BrokerStatusKind.connecting);
case BrokerRuntimeState.backoff:
final reason = lastError == BrokerLastError.none
? ''
: ' (${lastError.name})';
return BrokerStatus('Failed$reason', BrokerStatusKind.failed);
case BrokerRuntimeState.heldNoClock:
return const BrokerStatus('Held — no clock', BrokerStatusKind.held);
case BrokerRuntimeState.heldNoHeap:
return const BrokerStatus('Held — low heap', BrokerStatusKind.held);
case BrokerRuntimeState.down:
case BrokerRuntimeState.unknown:
return const BrokerStatus('Enabled', BrokerStatusKind.idle);
}
}
/// 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'] ?? '',
runtimeState: BrokerRuntimeState.fromWire(kv['state']),
lastError: BrokerLastError.fromWire(kv['last_error']),
jwtOwnerResolved: kv['jwt_owner_resolved'] ?? '',
iataResolved: kv['iata_resolved'] ?? '',
);
}
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,
BrokerRuntimeState? runtimeState,
BrokerLastError? lastError,
String? jwtOwnerResolved,
String? iataResolved,
}) {
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,
runtimeState: runtimeState ?? this.runtimeState,
lastError: lastError ?? this.lastError,
jwtOwnerResolved: jwtOwnerResolved ?? this.jwtOwnerResolved,
iataResolved: iataResolved ?? this.iataResolved,
);
}
/// 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,
);
}
}

Powered by TurnKey Linux.