diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0217df1..eafb650 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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? 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]; diff --git a/lib/main.dart b/lib/main.dart index 405f0eb..13c6522 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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( builder: (context, settingsService, child) { diff --git a/lib/screens/settings/observer_settings_view.dart b/lib/screens/settings/observer_settings_view.dart new file mode 100644 index 0000000..8bba3fa --- /dev/null +++ b/lib/screens/settings/observer_settings_view.dart @@ -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 createState() => _ObserverSettingsViewState(); +} + +class _ObserverSettingsViewState extends State { + 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 _refresh() async { + final svc = context.read(); + 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 _save() async { + final svc = context.read(); + final c = svc.config; + setState(() => _saving = true); + var ok = true; + + Future 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(); + 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( + segments: const [ + ButtonSegment(value: 0, label: Text('0°')), + 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 _brokerTiles(List 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' : ''}', + ), + ), + ]; + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c417084..6236a7a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { List _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(); + final showObserver = context.read().supported; return [ SettingsCategory( icon: Icons.settings_input_antenna, @@ -112,6 +118,13 @@ class _SettingsScreenState extends State { 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 { ]; } + Widget _observerPane(BuildContext context) => const ObserverSettingsView(); + Widget _radioRangePane(BuildContext context) { final l10n = context.l10n; final connector = context.watch(); diff --git a/lib/services/observer_config_service.dart b/lib/services/observer_config_service.dart index e538169..c060680 100644 --- a/lib/services/observer_config_service.dart +++ b/lib/services/observer_config_service.dart @@ -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;