diff --git a/lib/models/observer_config.dart b/lib/models/observer_config.dart index 70b824c..e22d2c9 100644 --- a/lib/models/observer_config.dart +++ b/lib/models/observer_config.dart @@ -117,6 +117,73 @@ enum BrokerApplyOutcome { 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. @@ -137,6 +204,10 @@ class BrokerConfig { this.jwtOwner = '', this.jwtEmail = '', this.caCert = '', + this.runtimeState = BrokerRuntimeState.unknown, + this.lastError = BrokerLastError.none, + this.jwtOwnerResolved = '', + this.iataResolved = '', }); final int slot; @@ -158,6 +229,18 @@ class BrokerConfig { 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; @@ -186,6 +269,34 @@ class BrokerConfig { : 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 kv) { @@ -210,6 +321,10 @@ class BrokerConfig { 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'] ?? '', ); } @@ -228,6 +343,10 @@ class BrokerConfig { String? jwtOwner, String? jwtEmail, String? caCert, + BrokerRuntimeState? runtimeState, + BrokerLastError? lastError, + String? jwtOwnerResolved, + String? iataResolved, }) { return BrokerConfig( slot: slot, @@ -245,6 +364,10 @@ class BrokerConfig { 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, ); } diff --git a/lib/screens/settings/broker_editor_screen.dart b/lib/screens/settings/broker_editor_screen.dart index c80099b..e152e29 100644 --- a/lib/screens/settings/broker_editor_screen.dart +++ b/lib/screens/settings/broker_editor_screen.dart @@ -121,6 +121,17 @@ class _BrokerEditorScreenState extends State { port: int.tryParse(_port.text.trim()) ?? -1, ).enableError; + /// Greyed placeholder showing the firmware-resolved default for a field whose + /// raw value is blank (#173). The blank raw key stays the source of truth — + /// this is hint text only, never written back. Null when nothing to resolve. + String? _resolvedHint(String resolved, {bool shortenKey = false}) { + if (resolved.isEmpty) return null; + if (shortenKey && resolved.length > 16) { + return 'Default: ${resolved.substring(0, 16)}… (this device)'; + } + return 'Default: $resolved'; + } + void _snack(String msg, {bool isError = false}) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -344,7 +355,15 @@ class _BrokerEditorScreenState extends State { if (_authType == BrokerAuthType.jwt) ...[ const SizedBox(height: 12), _field('broker_jwt_audience', _jwtAudience, 'JWT audience'), - _field('broker_jwt_owner', _jwtOwner, 'JWT owner'), + _field( + 'broker_jwt_owner', + _jwtOwner, + 'JWT owner', + hint: _resolvedHint( + _baseline.jwtOwnerResolved, + shortenKey: true, + ), + ), _field('broker_jwt_email', _jwtEmail, 'JWT email'), _field( 'broker_jwt_refresh', @@ -355,7 +374,12 @@ class _BrokerEditorScreenState extends State { ], const Divider(height: 24), _field('broker_topic_prefix', _topicPrefix, 'Topic prefix'), - _field('broker_iata_override', _iataOverride, 'IATA override'), + _field( + 'broker_iata_override', + _iataOverride, + 'IATA override', + hint: _resolvedHint(_baseline.iataResolved), + ), if (_transport != BrokerTransport.tcp) _field( 'broker_ca_cert', @@ -380,6 +404,7 @@ class _BrokerEditorScreenState extends State { String label, { bool number = false, int lines = 1, + String? hint, }) => Padding( padding: const EdgeInsets.only(bottom: 12), child: TextField( @@ -390,6 +415,7 @@ class _BrokerEditorScreenState extends State { inputFormatters: number ? [FilteringTextInputFormatter.digitsOnly] : null, decoration: InputDecoration( labelText: label, + hintText: hint, border: const OutlineInputBorder(), ), ), diff --git a/lib/screens/settings/mqtt_brokers_screen.dart b/lib/screens/settings/mqtt_brokers_screen.dart index ad316d7..367b227 100644 --- a/lib/screens/settings/mqtt_brokers_screen.dart +++ b/lib/screens/settings/mqtt_brokers_screen.dart @@ -213,6 +213,36 @@ class _MqttBrokersScreenState extends State { } } + IconData _statusIcon(BrokerStatusKind kind) { + switch (kind) { + case BrokerStatusKind.connected: + case BrokerStatusKind.idle: + return Icons.cloud_done; + case BrokerStatusKind.connecting: + case BrokerStatusKind.held: + return Icons.cloud_queue; + case BrokerStatusKind.failed: + case BrokerStatusKind.disabled: + return Icons.cloud_off; + } + } + + Color? _statusColor(BrokerStatusKind kind, ThemeData theme) { + switch (kind) { + case BrokerStatusKind.connected: + case BrokerStatusKind.idle: + return Colors.green; + case BrokerStatusKind.connecting: + return theme.colorScheme.primary; + case BrokerStatusKind.failed: + return theme.colorScheme.error; + case BrokerStatusKind.held: + return Colors.orange; + case BrokerStatusKind.disabled: + return null; + } + } + @override Widget build(BuildContext context) { final slot = _nextEmptySlot; @@ -244,13 +274,12 @@ class _MqttBrokersScreenState extends State { for (final b in _brokers) ListTile( leading: Icon( - b.enabled ? Icons.cloud_done : Icons.cloud_off, - color: b.enabled ? Colors.green : null, + _statusIcon(b.status.kind), + color: _statusColor(b.status.kind, Theme.of(context)), ), title: Text('[${b.slot}] ${b.url}'), subtitle: Text( - '${b.port} · ${b.transport.wire}' - '${b.enabled ? '' : ' · disabled'}', + '${b.port} · ${b.transport.wire} · ${b.status.label}', ), onTap: () => _openEditor(b), onLongPress: () => _menu(b), diff --git a/test/models/broker_status_test.dart b/test/models/broker_status_test.dart new file mode 100644 index 0000000..52478b7 --- /dev/null +++ b/test/models/broker_status_test.dart @@ -0,0 +1,105 @@ +// Broker runtime-status mapping (#93, firmware #172/#173). Maps the additive +// `state` + `last_error` dump fields to the human status the UI shows, and +// parses the resolved-default placeholders. Absent fields (older firmware) must +// read as today's plain enabled/disabled. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/observer_config.dart'; + +BrokerConfig _broker({ + bool enabled = true, + BrokerRuntimeState state = BrokerRuntimeState.unknown, + BrokerLastError lastError = BrokerLastError.none, +}) => BrokerConfig( + slot: 0, + url: 'mqtt.example', + port: 1883, + enabled: enabled, + runtimeState: state, + lastError: lastError, +); + +void main() { + group('BrokerConfig.status', () { + test('disabled overrides any runtime state', () { + final s = _broker(enabled: false, state: BrokerRuntimeState.up).status; + expect(s.label, 'Disabled'); + expect(s.kind, BrokerStatusKind.disabled); + }); + + test('up -> Connected', () { + final s = _broker(state: BrokerRuntimeState.up).status; + expect(s.label, 'Connected'); + expect(s.kind, BrokerStatusKind.connected); + }); + + test('connecting -> Connecting', () { + expect( + _broker(state: BrokerRuntimeState.connecting).status.kind, + BrokerStatusKind.connecting, + ); + }); + + test('backoff qualifies the failure with last_error', () { + final s = _broker( + state: BrokerRuntimeState.backoff, + lastError: BrokerLastError.auth, + ).status; + expect(s.label, 'Failed (auth)'); + expect(s.kind, BrokerStatusKind.failed); + }); + + test('backoff with no error class -> plain Failed', () { + final s = _broker(state: BrokerRuntimeState.backoff).status; + expect(s.label, 'Failed'); + expect(s.kind, BrokerStatusKind.failed); + }); + + test('held states are neutral, not failures', () { + expect( + _broker(state: BrokerRuntimeState.heldNoClock).status.kind, + BrokerStatusKind.held, + ); + expect( + _broker(state: BrokerRuntimeState.heldNoHeap).status.label, + 'Held — low heap', + ); + }); + + test('unknown state (older firmware) reads as plain Enabled', () { + final s = _broker(state: BrokerRuntimeState.unknown).status; + expect(s.label, 'Enabled'); + expect(s.kind, BrokerStatusKind.idle); + }); + }); + + group('BrokerConfig.fromWireFields runtime + resolved fields', () { + test('parses state + last_error + resolved defaults', () { + final b = BrokerConfig.fromWireFields(2, { + 'url': 'mqtt.example', + 'enabled': '1', + 'state': 'backoff', + 'last_error': 'tls', + 'jwt_owner_resolved': 'ABCDEF', + 'iata_resolved': 'HAO', + }); + expect(b.runtimeState, BrokerRuntimeState.backoff); + expect(b.lastError, BrokerLastError.tls); + expect(b.jwtOwnerResolved, 'ABCDEF'); + expect(b.iataResolved, 'HAO'); + expect(b.status.label, 'Failed (tls)'); + }); + + test('absent runtime fields default to unknown/none (backward compat)', () { + final b = BrokerConfig.fromWireFields(0, { + 'url': 'mqtt.example', + 'enabled': '1', + }); + expect(b.runtimeState, BrokerRuntimeState.unknown); + expect(b.lastError, BrokerLastError.none); + expect(b.jwtOwnerResolved, ''); + expect(b.iataResolved, ''); + expect(b.status.label, 'Enabled'); + }); + }); +}