feat(#64): observer settings UI + capability gate

- screens/settings/observer_settings_view.dart: staged-save pane for WiFi
  (ssid/pwd/enabled), MQTT region (iata/status_interval), display
  (always_on/rotation 0|180), with Save (changed keys only) + Refresh +
  stale/error banners; the broker pool renders read-only (enable/clear await
  the firmware F-task).
- settings_screen.dart: register the Observer category, gated on
  ObserverConfigService.supported (re-evaluated as the connector's offband_caps
  flips on connect/disconnect).
- connector/meshcore_connector.dart: parse the offband_caps byte (device-info
  byte 82, after path_hash_mode) + expose offbandCaps.
- services/observer_config_service.dart: supported computed live from the
  connector (version gate + cap bit).
- main.dart: provide ObserverConfigService.

Strings are English-only pending l10n ARB keys.

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

@ -195,6 +195,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool? _clientRepeat;
MeshCoreRadioStateSnapshot? _rememberedNonRepeatRadioState;
int? _firmwareVerCode;
int? _offbandCaps;
int _pathHashByteWidth = 1;
CompanionRadioStats? _latestRadioStats;
Stopwatch? _airtimeBumpStopwatch;
@ -445,6 +446,10 @@ class MeshCoreConnector extends ChangeNotifier {
}
int? get firmwareVerCode => _firmwareVerCode;
/// `offband_caps` capability bitfield from the device-info reply (v14+);
/// null on older firmware that sends a shorter frame.
int? get offbandCaps => _offbandCaps;
Map<String, String>? get currentCustomVars => _currentCustomVars;
int? get batteryMillivolts => _batteryMillivolts;
int? get storageUsedKb => _storageUsedKb;
@ -4044,6 +4049,9 @@ class MeshCoreConnector extends ChangeNotifier {
} else {
_pathHashByteWidth = 1;
}
// Offband config capability v14+ (byte 82): offband_caps bitfield, appended
// after path_hash_mode (additive older firmware sends a shorter frame).
_offbandCaps = frame.length >= 83 ? frame[82] : null;
// Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2];

@ -23,6 +23,7 @@ import 'services/chat_text_scale_service.dart';
import 'services/translation_service.dart';
import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart';
import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
@ -175,6 +176,7 @@ class MeshCoreApp extends StatelessWidget {
Provider.value(value: storage),
Provider.value(value: mapTileCacheService),
ChangeNotifierProvider.value(value: timeoutPredictionService),
ChangeNotifierProvider(create: (_) => ObserverConfigService(connector)),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {

@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/observer_config.dart';
import '../../services/observer_config_service.dart';
/// Observer settings pane: WiFi / MQTT (region) / display flat settings with
/// staged-save, plus a read-only view of the MQTT broker pool.
///
/// Staged-save model: every control edits LOCAL state; nothing reaches the
/// device until "Save", which sends only the changed keys (the firmware has no
/// transaction, so each is an individual SET awaiting its ACK). Device NVS is
/// the source of truth Refresh re-reads it.
///
/// TODO(l10n): strings are English-only pending ARB keys.
/// TODO(#64 / firmware F-task): broker enable/disable/clear + the broker editor
/// are blocked on the firmware wire path (no `enabled` SET; VIEW allowlist is
/// read-only), so the broker pool is read-only here for now.
class ObserverSettingsView extends StatefulWidget {
const ObserverSettingsView({super.key});
@override
State<ObserverSettingsView> createState() => _ObserverSettingsViewState();
}
class _ObserverSettingsViewState extends State<ObserverSettingsView> {
final _ssid = TextEditingController();
final _pwd = TextEditingController();
final _iata = TextEditingController();
final _statusInterval = TextEditingController();
bool _wifiEnabled = true;
bool _displayAlwaysOn = false;
int _rotation = 0;
bool _loading = false;
bool _saving = false;
bool _seeded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _refresh());
}
@override
void dispose() {
_ssid.dispose();
_pwd.dispose();
_iata.dispose();
_statusInterval.dispose();
super.dispose();
}
Future<void> _refresh() async {
final svc = context.read<ObserverConfigService>();
setState(() => _loading = true);
await svc.refresh();
_seedFromConfig(svc.config);
if (mounted) setState(() => _loading = false);
}
void _seedFromConfig(ObserverConfig? c) {
if (c == null) return;
_ssid.text = c.wifi.ssid;
_wifiEnabled = c.wifi.enabled;
_iata.text = c.mqtt.iata;
_statusInterval.text = '${c.mqtt.statusInterval}';
_displayAlwaysOn = c.display.alwaysOn;
_rotation = c.display.rotation;
_seeded = true;
}
Future<void> _save() async {
final svc = context.read<ObserverConfigService>();
final c = svc.config;
setState(() => _saving = true);
var ok = true;
Future<void> setIfChanged(bool changed, String key, String value) async {
if (changed) ok = await svc.setFlat(key, value) && ok;
}
await setIfChanged(
c == null || _ssid.text != c.wifi.ssid,
'wifi.ssid',
_ssid.text,
);
if (_pwd.text.isNotEmpty) {
ok = await svc.setFlat('wifi.pwd', _pwd.text) && ok;
}
await setIfChanged(
c == null || _wifiEnabled != c.wifi.enabled,
'wifi.enabled',
_wifiEnabled ? '1' : '0',
);
await setIfChanged(
c == null || _iata.text != c.mqtt.iata,
'mqtt.iata',
_iata.text,
);
await setIfChanged(
c == null || _statusInterval.text != '${c.mqtt.statusInterval}',
'mqtt.status_interval',
_statusInterval.text,
);
await setIfChanged(
c == null || _displayAlwaysOn != c.display.alwaysOn,
'display.always_on',
_displayAlwaysOn ? '1' : '0',
);
await setIfChanged(
c == null || _rotation != c.display.rotation,
'display.rotation',
'$_rotation',
);
_pwd.clear();
await svc.refresh();
_seedFromConfig(svc.config);
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? 'Observer settings saved'
: (svc.lastError ??
'Some changes failed — re-read from the device'),
),
backgroundColor: ok ? null : Theme.of(context).colorScheme.error,
),
);
}
@override
Widget build(BuildContext context) {
final svc = context.watch<ObserverConfigService>();
if (!_seeded && _loading) {
return const Center(child: CircularProgressIndicator());
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (svc.stale)
_banner(
Icons.warning_amber,
'Showing the last read — the device did not answer the latest refresh.',
),
if (svc.lastError != null) _banner(Icons.error_outline, svc.lastError!),
_actionRow(),
const SizedBox(height: 8),
_sectionTitle('WiFi'),
TextField(
controller: _ssid,
decoration: const InputDecoration(
labelText: 'SSID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _pwd,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password (leave blank to keep)',
border: OutlineInputBorder(),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('WiFi enabled'),
subtitle: Text(_wifiStatusLine(svc.config?.wifi)),
value: _wifiEnabled,
onChanged: (v) => setState(() => _wifiEnabled = v),
),
const Divider(height: 32),
_sectionTitle('MQTT / Region'),
TextField(
controller: _iata,
decoration: const InputDecoration(
labelText: 'Region tag (mqtt.iata)',
helperText: 'MQTT topic region, e.g. HAO',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _statusInterval,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Status interval (sec, 10-3600)',
border: OutlineInputBorder(),
),
),
const Divider(height: 32),
_sectionTitle('Display'),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Always on'),
subtitle: const Text('Keep the screen lit (USB/mains nodes)'),
value: _displayAlwaysOn,
onChanged: (v) => setState(() => _displayAlwaysOn = v),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const Text('Rotation'),
const SizedBox(width: 16),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 0, label: Text('')),
ButtonSegment(value: 180, label: Text('180°')),
],
selected: {_rotation},
onSelectionChanged: (s) => setState(() => _rotation = s.first),
),
],
),
),
const Divider(height: 32),
_sectionTitle('MQTT brokers'),
..._brokerTiles(svc.config?.brokers ?? const []),
],
);
}
Widget _actionRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: (_loading || _saving) ? null : _refresh,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Save'),
),
],
);
}
Widget _sectionTitle(String t) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(t, style: Theme.of(context).textTheme.titleMedium),
);
Widget _banner(IconData icon, String msg) {
final color = Theme.of(context).colorScheme.error;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(msg, style: TextStyle(color: color)),
),
],
),
);
}
String _wifiStatusLine(WifiConfig? w) {
if (w == null) return '';
final ip = w.ip != null ? ' · ${w.ip}' : '';
return 'Status: ${w.status.name}$ip';
}
List<Widget> _brokerTiles(List<BrokerConfig> brokers) {
final populated = brokers.where((b) => b.isPopulated).toList();
if (populated.isEmpty) {
return [
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('No brokers configured'),
),
];
}
return [
const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'Read-only — enable/disable/clear pending firmware support.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
for (final b in populated)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
b.enabled ? Icons.cloud_done : Icons.cloud_off,
color: b.enabled ? Colors.green : null,
),
title: Text('[${b.slot}] ${b.url}:${b.port}'),
subtitle: Text(
'${b.transport.wire} · ${b.authType.wire}'
'${b.passwordSet ? ' · pwd set' : ''}',
),
),
];
}
}

@ -10,10 +10,12 @@ import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/radio_settings.dart';
import '../services/app_debug_log_service.dart';
import '../services/observer_config_service.dart';
import '../helpers/snack_bar_builder.dart';
import 'settings/settings_shell.dart';
import 'settings/app_settings_view.dart';
import 'settings/message_settings_view.dart';
import 'settings/observer_settings_view.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart';
import 'companion_radio_stats_screen.dart';
@ -71,6 +73,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
List<SettingsCategory> _categories(BuildContext context) {
final l10n = context.l10n;
// Watch the connector so the list re-evaluates when the observer capability
// (device-info offband_caps) flips on connect/disconnect.
context.watch<MeshCoreConnector>();
final showObserver = context.read<ObserverConfigService>().supported;
return [
SettingsCategory(
icon: Icons.settings_input_antenna,
@ -112,6 +118,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: l10n.settings_deviceInfo,
builder: _devicePane,
),
if (showObserver)
SettingsCategory(
icon: Icons.cloud_outlined,
title: 'Observer',
subtitle: 'WiFi · MQTT brokers · display',
builder: _observerPane,
),
SettingsCategory(
icon: Icons.tune,
title: l10n.settings_appSettings,
@ -131,6 +144,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
];
}
Widget _observerPane(BuildContext context) => const ObserverSettingsView();
Widget _radioRangePane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();

@ -37,27 +37,14 @@ class ObserverConfigService extends ChangeNotifier {
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();
}
}
/// Whether the connected device supports the config command the version
/// gate AND the capability bit, read live from the connector's device-info
/// parse. The settings UI watches the connector, so the Observer category
/// appears/hides as this flips on connect/disconnect.
bool get supported => ObserverConfigClient.supportsConfig(
firmwareVerCode: _connector.firmwareVerCode ?? 0,
offbandCaps: _connector.offbandCaps ?? 0,
);
void _setError(String? e) {
_lastError = e;

Loading…
Cancel
Save

Powered by TurnKey Linux.