feat(#93): render broker runtime-state + resolved-default placeholders

Consume the additive OCFG_BROKERS dump fields from firmware #172/#173
(backward-compatible — absent fields read as today's enabled/disabled):

- BrokerRuntimeState (`state`) + BrokerLastError (`last_error`) + a pure
  BrokerConfig.status mapping -> Connected / Connecting / Failed (reason) /
  Held (no clock|low heap) / Enabled / Disabled; held shown neutral, not error.
- Brokers list shows the real status line + colour instead of just "enabled".
- Editor shows jwt_owner / iata_override resolved defaults (`jwt_owner_resolved`,
  `iata_resolved`) as greyed placeholders when the raw key is blank; only writes
  the raw key when the user enters a value (blank stays the source of truth).

Sourced from the pool dump (getBrokers); getBroker's per-field GET list is
unchanged pending the firmware GET-ability answer on #173. Unit-tested mapping,
parsing, and backward-compat (9 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/99/head
Strycher 4 weeks ago
parent 4d7921d2f7
commit 37d2ca9fb1

@ -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<String, String> 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,
);
}

@ -121,6 +121,17 @@ class _BrokerEditorScreenState extends State<BrokerEditorScreen> {
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<BrokerEditorScreen> {
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<BrokerEditorScreen> {
],
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<BrokerEditorScreen> {
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<BrokerEditorScreen> {
inputFormatters: number ? [FilteringTextInputFormatter.digitsOnly] : null,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
),

@ -213,6 +213,36 @@ class _MqttBrokersScreenState extends State<MqttBrokersScreen> {
}
}
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<MqttBrokersScreen> {
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),

@ -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');
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.