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); + }); + }); +}