You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
625 lines
20 KiB
625 lines
20 KiB
/// MeshCore stock companion config export format (#572, epic #568).
|
|
///
|
|
/// This is a faithful model of the JSON file the official MeshCore Companion
|
|
/// app writes from its Export Config screen, so that a stock export imports
|
|
/// into Offband and an Offband export imports into stock. The shape was
|
|
/// reverse-engineered from ten real exports across five radios (2359 contacts,
|
|
/// 76 channels); the evidence is recorded on issue #569.
|
|
///
|
|
/// Rules the format imposes, none of them obvious:
|
|
///
|
|
/// * **There is no version field.** Stock writes none, so we write none, and
|
|
/// validation is by shape alone. Every top-level section is independently
|
|
/// optional because the export screen lets the user deselect any of them.
|
|
/// * `public_key` and `private_key` are one unit: stock's single "Private
|
|
/// Identity Key" control emits both or neither.
|
|
/// * `radio_settings` mixes units: `frequency` is kHz, `bandwidth` is Hz, and
|
|
/// `coding_rate` is a bare denominator (5 means 4/5).
|
|
/// * Latitudes and longitudes are JSON **strings**, never numbers.
|
|
/// * `channels` has no index field; array position is the channel index.
|
|
/// * Contact timestamps are whatever the mesh reported and are not sanitized.
|
|
/// The real corpus contains values from epoch 14 to the year 2095.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import '../connector/meshcore_protocol.dart' show pubKeyToHex;
|
|
|
|
/// Thrown when a config file does not match the stock format.
|
|
///
|
|
/// Carries the offending JSON path so an import screen can tell the user which
|
|
/// part of the file is wrong rather than just "invalid file".
|
|
class StockConfigFormatException implements Exception {
|
|
StockConfigFormatException(this.message, {this.path});
|
|
|
|
final String message;
|
|
|
|
/// Dotted path to the offending value, e.g. `contacts[3].out_path_list`.
|
|
final String? path;
|
|
|
|
@override
|
|
String toString() => path == null
|
|
? 'StockConfigFormatException: $message'
|
|
: 'StockConfigFormatException at $path: $message';
|
|
}
|
|
|
|
/// Firmware caps a contact name at `char name[32]`, so 31 usable characters.
|
|
const int kStockMaxContactNameChars = 31;
|
|
|
|
const int _publicKeyBytes = 32;
|
|
const int _privateKeyBytes = 64;
|
|
const int _channelSecretBytes = 16;
|
|
|
|
/// Hop-hash widths the parser will accept, in bytes.
|
|
///
|
|
/// Only width 2 has been observed in the wild (a single path across the whole
|
|
/// corpus). 1 and 3 are accepted because the firmware path-length byte encodes
|
|
/// widths 1..3 (#309) and there is no reason stock would refuse to emit them.
|
|
/// Anything else is rejected rather than guessed at.
|
|
const Set<int> kStockPathHashWidths = {1, 2, 3};
|
|
|
|
Uint8List _parseHex(String value, String path, {int? expectBytes}) {
|
|
if (value.length.isOdd) {
|
|
throw StockConfigFormatException(
|
|
'expected hex, got an odd number of characters (${value.length})',
|
|
path: path,
|
|
);
|
|
}
|
|
final bytes = Uint8List(value.length ~/ 2);
|
|
for (var i = 0; i < bytes.length; i++) {
|
|
final byte = int.tryParse(value.substring(i * 2, i * 2 + 2), radix: 16);
|
|
if (byte == null) {
|
|
throw StockConfigFormatException('expected hex characters', path: path);
|
|
}
|
|
bytes[i] = byte;
|
|
}
|
|
if (expectBytes != null && bytes.length != expectBytes) {
|
|
throw StockConfigFormatException(
|
|
'expected $expectBytes bytes, got ${bytes.length}',
|
|
path: path,
|
|
);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
Map<String, dynamic> _asObject(Object? value, String path) {
|
|
if (value is! Map) {
|
|
throw StockConfigFormatException('expected an object', path: path);
|
|
}
|
|
return value.cast<String, dynamic>();
|
|
}
|
|
|
|
int _asInt(Object? value, String path) {
|
|
if (value is int) return value;
|
|
throw StockConfigFormatException('expected an integer', path: path);
|
|
}
|
|
|
|
bool _asBool(Object? value, String path) {
|
|
if (value is bool) return value;
|
|
throw StockConfigFormatException('expected a boolean', path: path);
|
|
}
|
|
|
|
String _asString(Object? value, String path) {
|
|
if (value is String) return value;
|
|
throw StockConfigFormatException('expected a string', path: path);
|
|
}
|
|
|
|
/// Stock writes coordinates as decimal strings, so `0.0` and `39.561991` both
|
|
/// appear verbatim. Dart's shortest-round-trip `double.toString()` reproduces
|
|
/// exactly that, which is why [_coordToJson] is a plain `toString`.
|
|
double _parseCoord(Object? value, String path) {
|
|
final text = _asString(value, path);
|
|
final parsed = double.tryParse(text);
|
|
if (parsed == null) {
|
|
throw StockConfigFormatException(
|
|
'expected a decimal coordinate string',
|
|
path: path,
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
String _coordToJson(double value) => value.toString();
|
|
|
|
/// LoRa parameters. Units differ per field. See the class doc on [StockConfig].
|
|
class StockRadioSettings {
|
|
const StockRadioSettings({
|
|
required this.frequencyKhz,
|
|
required this.bandwidthHz,
|
|
required this.spreadingFactor,
|
|
required this.codingRate,
|
|
required this.txPower,
|
|
});
|
|
|
|
/// `frequency`, in **kHz** (910525 means 910.525 MHz).
|
|
final int frequencyKhz;
|
|
|
|
/// `bandwidth`, in **Hz** (62500 means 62.5 kHz).
|
|
final int bandwidthHz;
|
|
|
|
final int spreadingFactor;
|
|
|
|
/// `coding_rate`, the **denominator only**, 5 means 4/5.
|
|
final int codingRate;
|
|
|
|
/// `tx_power`, in dBm.
|
|
final int txPower;
|
|
|
|
factory StockRadioSettings.fromJson(Map<String, dynamic> json, String path) {
|
|
return StockRadioSettings(
|
|
frequencyKhz: _asInt(json['frequency'], '$path.frequency'),
|
|
bandwidthHz: _asInt(json['bandwidth'], '$path.bandwidth'),
|
|
spreadingFactor: _asInt(
|
|
json['spreading_factor'],
|
|
'$path.spreading_factor',
|
|
),
|
|
codingRate: _asInt(json['coding_rate'], '$path.coding_rate'),
|
|
txPower: _asInt(json['tx_power'], '$path.tx_power'),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'frequency': frequencyKhz,
|
|
'bandwidth': bandwidthHz,
|
|
'spreading_factor': spreadingFactor,
|
|
'coding_rate': codingRate,
|
|
'tx_power': txPower,
|
|
};
|
|
}
|
|
|
|
/// The device's own advertised position. Both values are strings on the wire.
|
|
class StockPositionSettings {
|
|
const StockPositionSettings({
|
|
required this.latitude,
|
|
required this.longitude,
|
|
});
|
|
|
|
final double latitude;
|
|
final double longitude;
|
|
|
|
factory StockPositionSettings.fromJson(
|
|
Map<String, dynamic> json,
|
|
String path,
|
|
) {
|
|
return StockPositionSettings(
|
|
latitude: _parseCoord(json['latitude'], '$path.latitude'),
|
|
longitude: _parseCoord(json['longitude'], '$path.longitude'),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'latitude': _coordToJson(latitude),
|
|
'longitude': _coordToJson(longitude),
|
|
};
|
|
}
|
|
|
|
/// Stock's "Other Settings". Both fields are ints on the wire even though they
|
|
/// read as booleans in the UI: `manual_add_contacts` is emitted as 0/1, not
|
|
/// true/false, unlike the [StockAutoAddSettings] block.
|
|
class StockOtherSettings {
|
|
const StockOtherSettings({
|
|
required this.manualAddContacts,
|
|
required this.advertLocationPolicy,
|
|
});
|
|
|
|
final int manualAddContacts;
|
|
|
|
/// Shown in stock's UI as "Share Position in Advert".
|
|
final int advertLocationPolicy;
|
|
|
|
factory StockOtherSettings.fromJson(Map<String, dynamic> json, String path) {
|
|
return StockOtherSettings(
|
|
manualAddContacts: _asInt(
|
|
json['manual_add_contacts'],
|
|
'$path.manual_add_contacts',
|
|
),
|
|
advertLocationPolicy: _asInt(
|
|
json['advert_location_policy'],
|
|
'$path.advert_location_policy',
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'manual_add_contacts': manualAddContacts,
|
|
'advert_location_policy': advertLocationPolicy,
|
|
};
|
|
}
|
|
|
|
/// Stock's "Auto Add Settings". Booleans here really are JSON booleans.
|
|
class StockAutoAddSettings {
|
|
const StockAutoAddSettings({
|
|
required this.autoAddChat,
|
|
required this.autoAddRepeater,
|
|
required this.autoAddRoomServer,
|
|
required this.autoAddSensor,
|
|
required this.overwriteOldest,
|
|
required this.autoAddMaxHops,
|
|
});
|
|
|
|
final bool autoAddChat;
|
|
final bool autoAddRepeater;
|
|
final bool autoAddRoomServer;
|
|
final bool autoAddSensor;
|
|
final bool overwriteOldest;
|
|
|
|
/// `auto_add_max_hops`; stock renders 0 as "(no limit)".
|
|
final int autoAddMaxHops;
|
|
|
|
factory StockAutoAddSettings.fromJson(
|
|
Map<String, dynamic> json,
|
|
String path,
|
|
) {
|
|
return StockAutoAddSettings(
|
|
autoAddChat: _asBool(json['auto_add_chat'], '$path.auto_add_chat'),
|
|
autoAddRepeater: _asBool(
|
|
json['auto_add_repeater'],
|
|
'$path.auto_add_repeater',
|
|
),
|
|
autoAddRoomServer: _asBool(
|
|
json['auto_add_room_server'],
|
|
'$path.auto_add_room_server',
|
|
),
|
|
autoAddSensor: _asBool(json['auto_add_sensor'], '$path.auto_add_sensor'),
|
|
overwriteOldest: _asBool(
|
|
json['overwrite_oldest'],
|
|
'$path.overwrite_oldest',
|
|
),
|
|
autoAddMaxHops: _asInt(
|
|
json['auto_add_max_hops'],
|
|
'$path.auto_add_max_hops',
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'auto_add_chat': autoAddChat,
|
|
'auto_add_repeater': autoAddRepeater,
|
|
'auto_add_room_server': autoAddRoomServer,
|
|
'auto_add_sensor': autoAddSensor,
|
|
'overwrite_oldest': overwriteOldest,
|
|
'auto_add_max_hops': autoAddMaxHops,
|
|
};
|
|
}
|
|
|
|
/// One channel. The array position in [StockConfig.channels] is the index:
|
|
/// the record itself carries no index field.
|
|
class StockChannel {
|
|
const StockChannel({required this.name, required this.secret});
|
|
|
|
final String name;
|
|
|
|
/// The 16-byte PSK, hex on the wire.
|
|
final Uint8List secret;
|
|
|
|
factory StockChannel.fromJson(Map<String, dynamic> json, String path) {
|
|
return StockChannel(
|
|
name: _asString(json['name'], '$path.name'),
|
|
secret: _parseHex(
|
|
_asString(json['secret'], '$path.secret'),
|
|
'$path.secret',
|
|
expectBytes: _channelSecretBytes,
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'name': name,
|
|
'secret': pubKeyToHex(secret),
|
|
};
|
|
}
|
|
|
|
/// A contact's routing path, as carried by `out_path_list`.
|
|
///
|
|
/// Stock writes one comma-separated element per hop, each element being that
|
|
/// hop's hash in hex, so `"a1b2,c3d4"` is two hops at width 2. That maps
|
|
/// directly onto our width-aware path model (#309): [hopCount] is our hash
|
|
/// count and [hashWidth] is our per-hop width. Neither is inferred.
|
|
class StockOutPath {
|
|
StockOutPath({required this.hashWidth, required this.bytes})
|
|
: assert(hashWidth > 0),
|
|
assert(bytes.length % hashWidth == 0);
|
|
|
|
/// The `""` form of the field, as distinct from the key being null.
|
|
///
|
|
/// Both mean "no path" and the corpus contains 10 of these against 2448
|
|
/// nulls, but stock writes both and we do not know what distinguishes them.
|
|
/// Keeping them apart costs one constructor and makes an Offband re-export
|
|
/// byte-identical to its source, so we keep them apart.
|
|
StockOutPath.empty() : hashWidth = 0, bytes = _noBytes;
|
|
|
|
static final Uint8List _noBytes = Uint8List(0);
|
|
|
|
/// Bytes per hop hash, 1..3, or 0 for [StockOutPath.empty].
|
|
/// See [kStockPathHashWidths].
|
|
final int hashWidth;
|
|
|
|
/// Flattened hop hashes, `hopCount * hashWidth` bytes.
|
|
final Uint8List bytes;
|
|
|
|
int get hopCount => hashWidth == 0 ? 0 : bytes.length ~/ hashWidth;
|
|
|
|
/// Parses `out_path_list`. A null key returns null; `""` returns
|
|
/// [StockOutPath.empty]. Both mean the contact has no route.
|
|
static StockOutPath? fromJson(Object? value, String path) {
|
|
if (value == null) return null;
|
|
final text = _asString(value, path);
|
|
if (text.isEmpty) return StockOutPath.empty();
|
|
|
|
final elements = text.split(',');
|
|
final widths = elements.map((e) => e.length).toSet();
|
|
if (widths.length != 1) {
|
|
throw StockConfigFormatException(
|
|
'hop hashes must all be the same width, got $widths',
|
|
path: path,
|
|
);
|
|
}
|
|
final chars = widths.single;
|
|
if (chars.isOdd || !kStockPathHashWidths.contains(chars ~/ 2)) {
|
|
throw StockConfigFormatException(
|
|
'unsupported hop hash width of $chars characters',
|
|
path: path,
|
|
);
|
|
}
|
|
|
|
final width = chars ~/ 2;
|
|
final bytes = BytesBuilder();
|
|
for (final element in elements) {
|
|
bytes.add(_parseHex(element, path, expectBytes: width));
|
|
}
|
|
return StockOutPath(hashWidth: width, bytes: bytes.toBytes());
|
|
}
|
|
|
|
String toJson() {
|
|
if (hashWidth == 0) return '';
|
|
final hops = <String>[];
|
|
for (var i = 0; i < bytes.length; i += hashWidth) {
|
|
hops.add(pubKeyToHex(Uint8List.sublistView(bytes, i, i + hashWidth)));
|
|
}
|
|
return hops.join(',');
|
|
}
|
|
}
|
|
|
|
/// One contact. This is a near 1:1 serialization of the firmware's
|
|
/// `ContactInfo` struct, with [customName] as the one app-side addition.
|
|
class StockContact {
|
|
const StockContact({
|
|
required this.type,
|
|
required this.name,
|
|
required this.publicKey,
|
|
required this.flags,
|
|
required this.latitude,
|
|
required this.longitude,
|
|
required this.lastAdvert,
|
|
required this.lastModified,
|
|
this.customName,
|
|
this.outPath,
|
|
});
|
|
|
|
/// `ADV_TYPE_*`: 1 chat, 2 repeater, 3 room, 4 sensor.
|
|
final int type;
|
|
|
|
final String name;
|
|
|
|
/// App-side rename. Null in all 2359 contacts of the reference corpus, so
|
|
/// its round-trip behavior is carried but unverified (#569).
|
|
final String? customName;
|
|
|
|
final Uint8List publicKey;
|
|
|
|
/// Bit 0 is "favourite" (firmware `flags & 0x01`).
|
|
final int flags;
|
|
|
|
final double latitude;
|
|
final double longitude;
|
|
|
|
/// Epoch seconds by THEIR clock. **Not sanitized**: the reference corpus
|
|
/// ranges from 14 to the year 2095. Kept as a raw int so no caller mistakes
|
|
/// it for a trustworthy time.
|
|
final int lastAdvert;
|
|
|
|
/// Epoch seconds by OUR clock. Same caveat as [lastAdvert].
|
|
final int lastModified;
|
|
|
|
final StockOutPath? outPath;
|
|
|
|
bool get isFavourite => flags & 0x01 != 0;
|
|
|
|
factory StockContact.fromJson(Map<String, dynamic> json, String path) {
|
|
return StockContact(
|
|
type: _asInt(json['type'], '$path.type'),
|
|
name: _asString(json['name'], '$path.name'),
|
|
customName: json['custom_name'] == null
|
|
? null
|
|
: _asString(json['custom_name'], '$path.custom_name'),
|
|
publicKey: _parseHex(
|
|
_asString(json['public_key'], '$path.public_key'),
|
|
'$path.public_key',
|
|
expectBytes: _publicKeyBytes,
|
|
),
|
|
flags: _asInt(json['flags'], '$path.flags'),
|
|
latitude: _parseCoord(json['latitude'], '$path.latitude'),
|
|
longitude: _parseCoord(json['longitude'], '$path.longitude'),
|
|
lastAdvert: _asInt(json['last_advert'], '$path.last_advert'),
|
|
lastModified: _asInt(json['last_modified'], '$path.last_modified'),
|
|
outPath: StockOutPath.fromJson(
|
|
json['out_path_list'],
|
|
'$path.out_path_list',
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
if (name.length > kStockMaxContactNameChars) {
|
|
throw StockConfigFormatException(
|
|
'contact name is ${name.length} characters; stock firmware stores at '
|
|
'most $kStockMaxContactNameChars and would truncate it',
|
|
path: 'contacts[].name',
|
|
);
|
|
}
|
|
return {
|
|
'type': type,
|
|
'name': name,
|
|
'custom_name': customName,
|
|
'public_key': pubKeyToHex(publicKey),
|
|
'flags': flags,
|
|
'latitude': _coordToJson(latitude),
|
|
'longitude': _coordToJson(longitude),
|
|
'last_advert': lastAdvert,
|
|
'last_modified': lastModified,
|
|
'out_path_list': outPath?.toJson(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// A whole stock config export.
|
|
///
|
|
/// Every section is nullable, and null means "the user did not select this
|
|
/// section", which is distinct from an empty list. Absent sections are omitted
|
|
/// from [toJson] entirely rather than written as null, matching stock.
|
|
class StockConfig {
|
|
const StockConfig({
|
|
this.name,
|
|
this.publicKey,
|
|
this.privateKey,
|
|
this.radioSettings,
|
|
this.positionSettings,
|
|
this.otherSettings,
|
|
this.autoAddSettings,
|
|
this.channels,
|
|
this.contacts,
|
|
});
|
|
|
|
final String? name;
|
|
|
|
/// 32 bytes. Travels as a unit with [privateKey]: stock's identity control
|
|
/// emits both or neither.
|
|
final Uint8List? publicKey;
|
|
|
|
/// 64 bytes. See [publicKey].
|
|
final Uint8List? privateKey;
|
|
|
|
final StockRadioSettings? radioSettings;
|
|
final StockPositionSettings? positionSettings;
|
|
final StockOtherSettings? otherSettings;
|
|
final StockAutoAddSettings? autoAddSettings;
|
|
|
|
/// Ordered; array position is the channel index.
|
|
final List<StockChannel>? channels;
|
|
|
|
final List<StockContact>? contacts;
|
|
|
|
/// Parses a decoded JSON object.
|
|
///
|
|
/// Unknown top-level keys are ignored, not rejected: the format has no
|
|
/// version field, so tolerating additions is the only way to survive a stock
|
|
/// release that adds one.
|
|
factory StockConfig.fromJson(Map<String, dynamic> json) {
|
|
final channelsJson = json['channels'];
|
|
final contactsJson = json['contacts'];
|
|
|
|
return StockConfig(
|
|
name: json['name'] == null ? null : _asString(json['name'], 'name'),
|
|
publicKey: json['public_key'] == null
|
|
? null
|
|
: _parseHex(
|
|
_asString(json['public_key'], 'public_key'),
|
|
'public_key',
|
|
expectBytes: _publicKeyBytes,
|
|
),
|
|
privateKey: json['private_key'] == null
|
|
? null
|
|
: _parseHex(
|
|
_asString(json['private_key'], 'private_key'),
|
|
'private_key',
|
|
expectBytes: _privateKeyBytes,
|
|
),
|
|
radioSettings: json['radio_settings'] == null
|
|
? null
|
|
: StockRadioSettings.fromJson(
|
|
_asObject(json['radio_settings'], 'radio_settings'),
|
|
'radio_settings',
|
|
),
|
|
positionSettings: json['position_settings'] == null
|
|
? null
|
|
: StockPositionSettings.fromJson(
|
|
_asObject(json['position_settings'], 'position_settings'),
|
|
'position_settings',
|
|
),
|
|
otherSettings: json['other_settings'] == null
|
|
? null
|
|
: StockOtherSettings.fromJson(
|
|
_asObject(json['other_settings'], 'other_settings'),
|
|
'other_settings',
|
|
),
|
|
autoAddSettings: json['auto_add_settings'] == null
|
|
? null
|
|
: StockAutoAddSettings.fromJson(
|
|
_asObject(json['auto_add_settings'], 'auto_add_settings'),
|
|
'auto_add_settings',
|
|
),
|
|
channels: channelsJson == null
|
|
? null
|
|
: _parseList(
|
|
channelsJson,
|
|
'channels',
|
|
(item, path) =>
|
|
StockChannel.fromJson(_asObject(item, path), path),
|
|
),
|
|
contacts: contactsJson == null
|
|
? null
|
|
: _parseList(
|
|
contactsJson,
|
|
'contacts',
|
|
(item, path) =>
|
|
StockContact.fromJson(_asObject(item, path), path),
|
|
),
|
|
);
|
|
}
|
|
|
|
static List<T> _parseList<T>(
|
|
Object? value,
|
|
String path,
|
|
T Function(Object? item, String itemPath) parse,
|
|
) {
|
|
if (value is! List) {
|
|
throw StockConfigFormatException('expected an array', path: path);
|
|
}
|
|
final result = <T>[];
|
|
for (var i = 0; i < value.length; i++) {
|
|
result.add(parse(value[i], '$path[$i]'));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Parses raw file text.
|
|
factory StockConfig.parse(String source) {
|
|
final Object? decoded;
|
|
try {
|
|
decoded = jsonDecode(source);
|
|
} on FormatException catch (e) {
|
|
throw StockConfigFormatException('file is not valid JSON: ${e.message}');
|
|
}
|
|
return StockConfig.fromJson(_asObject(decoded, r'$'));
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
if (name != null) 'name': name,
|
|
if (publicKey != null) 'public_key': pubKeyToHex(publicKey!),
|
|
if (privateKey != null) 'private_key': pubKeyToHex(privateKey!),
|
|
if (radioSettings != null) 'radio_settings': radioSettings!.toJson(),
|
|
if (positionSettings != null)
|
|
'position_settings': positionSettings!.toJson(),
|
|
if (otherSettings != null) 'other_settings': otherSettings!.toJson(),
|
|
if (autoAddSettings != null)
|
|
'auto_add_settings': autoAddSettings!.toJson(),
|
|
if (channels != null) 'channels': [for (final c in channels!) c.toJson()],
|
|
if (contacts != null) 'contacts': [for (final c in contacts!) c.toJson()],
|
|
};
|
|
}
|
|
|
|
String encode() => jsonEncode(toJson());
|
|
}
|