From 16c2b4a770c8062426823e51a2705e277fc4df1b Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 13 Aug 2026 02:35:45 -0400 Subject: [PATCH] feat(#573): stock config export service Gathers live device state into a StockConfig, section by section, matching stock's Export Config screen where each section is independently selectable and the file simply omits what was not ticked. A requested section that cannot be gathered is reported in the result rather than dropped, with the reason kept specific: unsupported (firmware built without identity export) is distinct from noReply and rejected, so the export screen can say the radio cannot do it instead of offering a pointless retry. Two unit traps handled explicitly: - currentFreqHz is misnamed; the value is kHz, which is also what stock's frequency field carries, so it passes through unconverted. currentBwHz really is Hz. The mismatch exists in both our state and the file. - other_settings.manual_add_contacts must be the raw device byte. The connector's _manualAddContacts is an inverted derived view of bit 0 (firmware treats a clear bit as auto-add enabled), so exporting it would have written the wrong value. The raw byte is now retained and exposed, with the inversion documented at the parse site. No behavior change. Path mapping keeps our hash count and per-hop width (#309) intact: stock encodes one hash per comma-separated element, so the width survives as element length. A flood route, an over-long declared hop count, or a width stock cannot express all yield no path rather than a guessed one. 13 tests over the pure mapping functions. --- lib/connector/meshcore_connector.dart | 14 +- lib/services/stock_config_export_service.dart | 260 ++++++++++++++++++ .../stock_config_export_service_test.dart | 184 +++++++++++++ 3 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 lib/services/stock_config_export_service.dart create mode 100644 test/services/stock_config_export_service_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index eba79ea..77cf2ef 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -388,6 +388,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _webInitialHandshakeRequestSent = false; bool _preserveContactsOnRefresh = false; int _autoAddMaxHops = 0; + int _manualAddContactsRaw = 0; bool _autoAddUsers = false; bool _autoAddRepeaters = false; bool _autoAddRoomServers = false; @@ -638,6 +639,12 @@ class MeshCoreConnector extends ChangeNotifier { int get telemetryModeLoc => _telemetryModeLoc; int get telemetryModeEnv => _telemetryModeEnv; int get advertLocationPolicy => _advertLocPolicy; + + /// The device's `manual_add_contacts` pref exactly as reported, for callers + /// that must reproduce it rather than interpret it (stock config export, + /// #573). [_manualAddContacts] is a derived, inverted view of bit 0 and is + /// not what belongs in an export file. + int get manualAddContactsRaw => _manualAddContactsRaw; int get multiAcks => _multiAcks; bool? get clientRepeat => _clientRepeat; @@ -5667,7 +5674,12 @@ class MeshCoreConnector extends ChangeNotifier { _telemetryModeEnv = telemetryFlag >> 2 & 0x03; _telemetryModeLoc = telemetryFlag >> 4 & 0x03; - _manualAddContacts = reader.readByte() & 0x01 == 0x00; + _manualAddContactsRaw = reader.readByte(); + // Firmware treats bit 0 as "manual add", so auto-add is on when it is + // clear (`isAutoAddEnabled()` in MyMesh.cpp). This flag therefore means + // "device is in auto-add mode", despite its name, and drives the one-shot + // default-applying pass in _checkManualAddContacts. + _manualAddContacts = _manualAddContactsRaw & 0x01 == 0x00; _currentFreqHz = reader.readUInt32LE(); _currentBwHz = reader.readUInt32LE(); diff --git a/lib/services/stock_config_export_service.dart b/lib/services/stock_config_export_service.dart new file mode 100644 index 0000000..a968acc --- /dev/null +++ b/lib/services/stock_config_export_service.dart @@ -0,0 +1,260 @@ +/// Builds a stock-compatible config export from the connected device (#573, +/// epic #568). +/// +/// The wire format lives in `models/stock_config.dart`; this service is only +/// concerned with reading live state and mapping it across. Section selection +/// mirrors stock's Export Config screen, where every section is individually +/// checkable and the file simply omits what was not selected. +library; + +import 'dart:typed_data'; + +import '../connector/meshcore_connector.dart'; +import '../models/channel.dart'; +import '../models/contact.dart'; +import '../models/stock_config.dart'; + +/// One checkbox on the export screen. [identity] covers the public and private +/// key together, matching stock's single "Private Identity Key" control. +enum StockConfigSection { + name, + identity, + radioSettings, + positionSettings, + otherSettings, + autoAddSettings, + channels, + contacts, +} + +/// Why a requested section did not make it into the file. +enum StockConfigOmission { + /// The device is not connected, or did not answer in time. + noReply, + + /// The radio's firmware was built without the feature. Retrying is pointless. + unsupported, + + /// The device refused the request. + rejected, + + /// The device has not reported this value, so there is nothing to write. + unavailable, +} + +/// The outcome of an export attempt. +/// +/// [omitted] is the important half: a section the user ticked that could not be +/// gathered must be reported, never dropped quietly. The export screen shows +/// these before writing the file. +class StockConfigExportResult { + const StockConfigExportResult({ + required this.config, + required this.included, + required this.omitted, + }); + + final StockConfig config; + final Set included; + final Map omitted; + + bool get isComplete => omitted.isEmpty; +} + +class StockConfigExportService { + StockConfigExportService(this._connector); + + final MeshCoreConnector _connector; + + /// Gathers [sections] from the connected device. + /// + /// Reading the identity is the only step that talks to the radio; everything + /// else is already in connector state from the SELF_INFO handshake and the + /// contact and channel syncs. + Future build({ + required Set sections, + }) async { + final included = {}; + final omitted = {}; + + void include(StockConfigSection section) => included.add(section); + void omit(StockConfigSection section, StockConfigOmission why) => + omitted[section] = why; + + String? name; + if (sections.contains(StockConfigSection.name)) { + name = _connector.selfName; + if (name == null) { + omit(StockConfigSection.name, StockConfigOmission.unavailable); + } else { + include(StockConfigSection.name); + } + } + + Uint8List? publicKey; + Uint8List? privateKey; + if (sections.contains(StockConfigSection.identity)) { + final result = await _connector.exportPrivateKey(); + switch (result.outcome) { + case IdentityTransfer.ok: + // The keys travel as a pair, so a missing public key means we write + // neither rather than half an identity. + final self = _connector.selfPublicKey; + if (self == null) { + omit(StockConfigSection.identity, StockConfigOmission.unavailable); + } else { + publicKey = self; + privateKey = result.identity; + include(StockConfigSection.identity); + } + case IdentityTransfer.unsupported: + omit(StockConfigSection.identity, StockConfigOmission.unsupported); + case IdentityTransfer.rejected: + omit(StockConfigSection.identity, StockConfigOmission.rejected); + case IdentityTransfer.noReply: + omit(StockConfigSection.identity, StockConfigOmission.noReply); + } + } + + StockRadioSettings? radio; + if (sections.contains(StockConfigSection.radioSettings)) { + radio = _buildRadioSettings(); + if (radio == null) { + omit(StockConfigSection.radioSettings, StockConfigOmission.unavailable); + } else { + include(StockConfigSection.radioSettings); + } + } + + StockPositionSettings? position; + if (sections.contains(StockConfigSection.positionSettings)) { + position = StockPositionSettings( + latitude: _connector.selfLatitude ?? 0, + longitude: _connector.selfLongitude ?? 0, + ); + include(StockConfigSection.positionSettings); + } + + StockOtherSettings? other; + if (sections.contains(StockConfigSection.otherSettings)) { + other = StockOtherSettings( + // The raw device byte, not the connector's inverted convenience flag. + manualAddContacts: _connector.manualAddContactsRaw, + advertLocationPolicy: _connector.advertLocationPolicy, + ); + include(StockConfigSection.otherSettings); + } + + StockAutoAddSettings? autoAdd; + if (sections.contains(StockConfigSection.autoAddSettings)) { + autoAdd = StockAutoAddSettings( + autoAddChat: _connector.autoAddUsers ?? false, + autoAddRepeater: _connector.autoAddRepeaters ?? false, + autoAddRoomServer: _connector.autoAddRoomServers ?? false, + autoAddSensor: _connector.autoAddSensors ?? false, + overwriteOldest: _connector.autoAddOverwriteOldest ?? false, + autoAddMaxHops: _connector.autoAddMaxHops, + ); + include(StockConfigSection.autoAddSettings); + } + + List? channels; + if (sections.contains(StockConfigSection.channels)) { + channels = [ + for (final channel in _connector.channels) + if (!channel.isEmpty) toStockChannel(channel), + ]; + include(StockConfigSection.channels); + } + + List? contacts; + if (sections.contains(StockConfigSection.contacts)) { + contacts = [ + for (final contact in _connector.contacts) toStockContact(contact), + ]; + include(StockConfigSection.contacts); + } + + return StockConfigExportResult( + config: StockConfig( + name: name, + publicKey: publicKey, + privateKey: privateKey, + radioSettings: radio, + positionSettings: position, + otherSettings: other, + autoAddSettings: autoAdd, + channels: channels, + contacts: contacts, + ), + included: included, + omitted: omitted, + ); + } + + /// Null until the device has reported its radio parameters. Partial radio + /// state is never written: stock's reader takes the five values together. + StockRadioSettings? _buildRadioSettings() { + // `currentFreqHz` is misnamed: the value is kHz, which is what stock's + // `frequency` field also carries, so it passes through unconverted. + // `currentBwHz` really is Hz, matching stock's `bandwidth`. The units + // differ between the two fields in both our state and the file. + final frequencyKhz = _connector.currentFreqHz; + final bandwidthHz = _connector.currentBwHz; + final sf = _connector.currentSf; + final cr = _connector.currentCr; + final txPower = _connector.currentTxPower; + if (frequencyKhz == null || + bandwidthHz == null || + sf == null || + cr == null || + txPower == null) { + return null; + } + return StockRadioSettings( + frequencyKhz: frequencyKhz, + bandwidthHz: bandwidthHz, + spreadingFactor: sf, + codingRate: cr, + txPower: txPower, + ); + } +} + +/// Maps one of our channels onto the stock record. The channel's index is not +/// written: stock derives it from array position. +StockChannel toStockChannel(Channel channel) => + StockChannel(name: channel.name, secret: channel.psk); + +/// Maps one of our contacts onto the stock record. +StockContact toStockContact(Contact contact) { + return StockContact( + type: contact.type, + name: contact.name, + publicKey: contact.publicKey, + flags: contact.flags, + latitude: contact.latitude ?? 0, + longitude: contact.longitude ?? 0, + lastAdvert: _epochSeconds(contact.lastSeen), + lastModified: _epochSeconds(contact.lastModified ?? contact.lastSeen), + outPath: _toStockOutPath(contact), + ); +} + +/// Our path is a hash count plus a per-hop width (#309); stock writes one +/// comma-separated hash per hop, so the width survives as each element's +/// length. A flood route (`pathLength` of -1) and an empty path both mean +/// "no route", which stock spells as a null field. +StockOutPath? _toStockOutPath(Contact contact) { + if (contact.pathLength <= 0 || contact.path.isEmpty) return null; + final width = contact.pathHashWidth; + if (!kStockPathHashWidths.contains(width)) return null; + final usable = contact.pathLength * width; + if (usable > contact.path.length) return null; + return StockOutPath( + hashWidth: width, + bytes: Uint8List.sublistView(contact.path, 0, usable), + ); +} + +int _epochSeconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; diff --git a/test/services/stock_config_export_service_test.dart b/test/services/stock_config_export_service_test.dart new file mode 100644 index 0000000..a994883 --- /dev/null +++ b/test/services/stock_config_export_service_test.dart @@ -0,0 +1,184 @@ +// #573 (epic #568): mapping live device state onto the stock export format. +// +// The mapping functions are pure, so they are tested directly. The parts that +// need a radio (identity read, connector state) belong to the T1 hardware gate. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/channel.dart'; +import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/stock_config.dart'; +import 'package:meshcore_open/services/stock_config_export_service.dart'; + +Uint8List bytes(List values) => Uint8List.fromList(values); + +Contact makeContact({ + int type = 1, + int flags = 0, + String name = 'Node', + double? latitude, + double? longitude, + int pathLength = -1, + int pathHashWidth = 1, + List path = const [], + DateTime? lastSeen, + DateTime? lastModified, +}) { + return Contact( + publicKey: Uint8List(32), + name: name, + type: type, + flags: flags, + pathLength: pathLength, + pathHashWidth: pathHashWidth, + path: bytes(path), + latitude: latitude, + longitude: longitude, + lastSeen: lastSeen ?? DateTime.fromMillisecondsSinceEpoch(1785706493000), + lastModified: lastModified, + ); +} + +void main() { + group('channel mapping', () { + test('carries name and psk, and writes no index', () { + final stock = toStockChannel( + Channel(index: 4, name: 'Public', psk: Uint8List(16)), + ); + + expect(stock.name, 'Public'); + expect(stock.secret, hasLength(16)); + expect(stock.toJson().keys, unorderedEquals(['name', 'secret'])); + }); + }); + + group('contact mapping', () { + test('an absent position becomes zero, which is what stock writes', () { + final stock = toStockContact(makeContact()); + + expect(stock.latitude, 0); + expect(stock.toJson()['latitude'], '0.0'); + }); + + test('a position is carried through as a decimal string', () { + final stock = toStockContact( + makeContact(latitude: 39.561991, longitude: -84.635731), + ); + + expect(stock.toJson()['latitude'], '39.561991'); + expect(stock.toJson()['longitude'], '-84.635731'); + }); + + test('timestamps convert to epoch seconds', () { + final stock = toStockContact( + makeContact( + lastSeen: DateTime.fromMillisecondsSinceEpoch(1785706493000), + lastModified: DateTime.fromMillisecondsSinceEpoch(1785706508000), + ), + ); + + expect(stock.lastAdvert, 1785706493); + expect(stock.lastModified, 1785706508); + }); + + test('a contact with no recorded modification falls back to last seen', () { + final stock = toStockContact( + makeContact( + lastSeen: DateTime.fromMillisecondsSinceEpoch(1785706493000), + ), + ); + + expect(stock.lastModified, 1785706493); + }); + + test('a flood route is written as no path', () { + // pathLength of -1 means flood, which stock has no representation for. + final stock = toStockContact(makeContact(pathLength: -1)); + + expect(stock.outPath, isNull); + expect(stock.toJson()['out_path_list'], isNull); + }); + + test('a width-aware path keeps both its hop count and its width', () { + final stock = toStockContact( + makeContact( + pathLength: 2, + pathHashWidth: 2, + path: [0xa1, 0xb2, 0xc3, 0xd4], + ), + ); + + expect(stock.outPath!.hopCount, 2); + expect(stock.outPath!.hashWidth, 2); + expect(stock.toJson()['out_path_list'], 'a1b2,c3d4'); + }); + + test('a single-width path round trips to comma separated bytes', () { + final stock = toStockContact( + makeContact(pathLength: 3, pathHashWidth: 1, path: [0x0a, 0x0b, 0x0c]), + ); + + expect(stock.toJson()['out_path_list'], '0a,0b,0c'); + }); + + test('trailing path bytes beyond the hop count are not written', () { + // The buffer can be longer than the live path; only hopCount * width + // bytes are meaningful. + final stock = toStockContact( + makeContact( + pathLength: 1, + pathHashWidth: 2, + path: [0xa1, 0xb2, 0xff, 0xff], + ), + ); + + expect(stock.toJson()['out_path_list'], 'a1b2'); + }); + + test( + 'a path shorter than its declared hop count is dropped, not guessed', + () { + final stock = toStockContact( + makeContact(pathLength: 4, pathHashWidth: 2, path: [0xa1, 0xb2]), + ); + + expect(stock.outPath, isNull); + }, + ); + + test('a hash width stock cannot express is dropped', () { + final stock = toStockContact( + makeContact(pathLength: 1, pathHashWidth: 4, path: [1, 2, 3, 4]), + ); + + expect(stock.outPath, isNull); + }); + + test('flags and type pass through untouched', () { + final stock = toStockContact(makeContact(type: 3, flags: 15)); + + expect(stock.type, 3); + expect(stock.flags, 15); + expect(stock.isFavourite, isTrue); + }); + }); + + group('export result', () { + test('is only complete when nothing was omitted', () { + const complete = StockConfigExportResult( + config: StockConfig(), + included: {StockConfigSection.name}, + omitted: {}, + ); + const partial = StockConfigExportResult( + config: StockConfig(), + included: {}, + omitted: {StockConfigSection.identity: StockConfigOmission.unsupported}, + ); + + expect(complete.isComplete, isTrue); + expect(partial.isComplete, isFalse); + }); + }); +}