Merge pull request #467 from OffbandMesh/dev
release: promote 1.3.0+64 to main (first production release)main v1.3.0
commit
266325addd
@ -0,0 +1,2 @@
|
||||
github: [Strycher]
|
||||
ko_fi: offband
|
||||
@ -0,0 +1,28 @@
|
||||
🚀 **Offband Meshcore 1.3.0 is here, and it's the first build on the Google Play production track.**
|
||||
|
||||
Our biggest feature drop since the 1.2.0 redesign. Highlights:
|
||||
|
||||
🙂 **Reactions with names.** Incoming reactions now show **who** reacted, not just a count, and emoji reactions from **PocketMesh and MeshCore One** are read and displayed properly instead of arriving as raw text.
|
||||
|
||||
🛰️ **Observer config profiles.** Import an Observer config from the shared catalog or a URL, see a **field-by-field diff** against what your radio has now, and apply it, straight from Observer settings.
|
||||
|
||||
📤 **Export logs, capture serial.** The App and BLE logs now have a **Share** action (Save As on desktop, download on web, share sheet on mobile), plus a new **serial / boot-log capture** screen with a Reboot Device action.
|
||||
|
||||
📶 **Richer path diagnostics.** The message Path screen now shows path type and **per-message SNR and RSSI**, so you can read link quality hop by hop.
|
||||
|
||||
🗄️ **Desktop history reunited.** On Windows and Linux, message history that ended up split across several older database files is now brought back together automatically on launch, merged into one database with your original files left untouched.
|
||||
|
||||
⚙️ Plus a **reorganized Settings** screen, a fix so sending a **max-length message no longer wedges** your outbox, **desktop builds that run on a clean machine** (Windows and Linux), and Android battery-optimization guidance.
|
||||
|
||||
🟢 **Open to everyone.** This is the first official public release: no tester list, no sideloading, no waiting. Search **Offband MeshCore** on Google Play, or grab it from the links below.
|
||||
|
||||
🧪 **The testing track stays open.** Want the latest before it reaches production? Join the test track and you'll get new features as I build them. Fair warning: testing builds are exactly that. Features land there first precisely because they are not confirmed stable yet. If something acts weird, that's the trade, and your bug reports are what make the production releases solid for everyone else.
|
||||
|
||||
**📥 Get it on Google Play:** search **Offband MeshCore**, or https://play.google.com/store/apps/details?id=app.offband.meshcore
|
||||
💻 **Desktop (Windows / Linux) and Android APK:** https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.3.0
|
||||
🌐 **Or run it in your browser:** https://offband.app
|
||||
🔗 Project home: https://offband.org
|
||||
|
||||
💚 **A big thank-you** to the **OKIMesh community** (https://okimesh.org/) for the ideas, time, hardware, and testing that got this here. This release exists because of you.
|
||||
|
||||
Offband is free and open source and always will be. If you'd like to help keep it going, there's a donation page at https://offband.org/donate. Thank you all. 📡
|
||||
@ -0,0 +1,93 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'meshcore_protocol.dart';
|
||||
|
||||
/// Outcome of feeding one `0xC4` frame to [CaplogReassembler.accept].
|
||||
enum CaplogStatus { started, chunk, completed, truncated, ignored }
|
||||
|
||||
/// Result of [CaplogReassembler.accept]. [bytes] is set on [CaplogStatus.completed]
|
||||
/// and [CaplogStatus.truncated]; [expected] is set on [CaplogStatus.truncated].
|
||||
class CaplogEvent {
|
||||
const CaplogEvent(this.status, {this.bytes, this.expected});
|
||||
final CaplogStatus status;
|
||||
final Uint8List? bytes;
|
||||
final int? expected;
|
||||
}
|
||||
|
||||
/// Pure state machine that reassembles an Offband caplog (`0xC4`) streamed dump:
|
||||
/// START `[0xC4, 0x01, total_len(uint32 LE)]` → CHUNK `[0xC4, 0x02, <bytes>]*`
|
||||
/// → END `[0xC4, 0x03]`.
|
||||
///
|
||||
/// A CHUNK or END arriving without a preceding START, and any non-`0xC4` frame,
|
||||
/// is [CaplogStatus.ignored]. Kept free of connector/transport concerns so the
|
||||
/// reassembly is unit-testable in isolation. (#430)
|
||||
class CaplogReassembler {
|
||||
final BytesBuilder _buffer = BytesBuilder();
|
||||
int? _expected;
|
||||
bool _active = false;
|
||||
|
||||
CaplogEvent accept(Uint8List frame) {
|
||||
if (frame.length < 2 || frame[0] != respCodeOffbandCaplog) {
|
||||
return const CaplogEvent(CaplogStatus.ignored);
|
||||
}
|
||||
switch (frame[1]) {
|
||||
case caplogSubStart:
|
||||
_buffer.clear();
|
||||
// total_len is optional/defensive: an older device might omit it. When
|
||||
// absent we can't detect truncation, but still reassemble what arrives.
|
||||
_expected = frame.length >= 6 ? readUint32LE(frame, 2) : null;
|
||||
_active = true;
|
||||
return const CaplogEvent(CaplogStatus.started);
|
||||
case caplogSubChunk:
|
||||
if (!_active) return const CaplogEvent(CaplogStatus.ignored);
|
||||
if (frame.length > 2) _buffer.add(frame.sublist(2));
|
||||
return const CaplogEvent(CaplogStatus.chunk);
|
||||
case caplogSubEnd:
|
||||
if (!_active) return const CaplogEvent(CaplogStatus.ignored);
|
||||
_active = false;
|
||||
final bytes = _buffer.takeBytes();
|
||||
final expected = _expected;
|
||||
_expected = null;
|
||||
if (expected != null && bytes.length != expected) {
|
||||
return CaplogEvent(
|
||||
CaplogStatus.truncated,
|
||||
bytes: bytes,
|
||||
expected: expected,
|
||||
);
|
||||
}
|
||||
return CaplogEvent(CaplogStatus.completed, bytes: bytes);
|
||||
default:
|
||||
return const CaplogEvent(CaplogStatus.ignored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when a caplog download is rejected because another streamed response
|
||||
/// is already in flight on the device (firmware answers `RESP_CODE_ERR`). (#430)
|
||||
class CaplogBusyException implements Exception {
|
||||
const CaplogBusyException();
|
||||
@override
|
||||
String toString() =>
|
||||
'CaplogBusyException: device busy, another stream is in flight';
|
||||
}
|
||||
|
||||
/// Thrown when the reassembled caplog byte count doesn't match the length the
|
||||
/// device announced in its START frame. (#430)
|
||||
class CaplogTruncatedException implements Exception {
|
||||
const CaplogTruncatedException({
|
||||
required this.received,
|
||||
required this.expected,
|
||||
this.chunks,
|
||||
});
|
||||
final int received;
|
||||
final int expected;
|
||||
|
||||
/// Number of CHUNK frames the client accumulated before END, a diagnostic to
|
||||
/// tell client/transport frame loss apart from the firmware streaming short.
|
||||
final int? chunks;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CaplogTruncatedException: received $received of $expected bytes'
|
||||
'${chunks != null ? ' in $chunks chunks' : ''}';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,107 @@
|
||||
import '../models/config_profile.dart';
|
||||
import 'config_profile_writes.dart';
|
||||
|
||||
/// Builds the human-facing diff for a profile apply (#406): current → new, per
|
||||
/// field, categorized so the preview can render its two-tier confirm.
|
||||
///
|
||||
/// Pure, the screen fetches current device values and passes them in, so this
|
||||
/// is unit-testable without a device.
|
||||
|
||||
enum DiffKind {
|
||||
/// Device has no value; the profile sets one.
|
||||
add,
|
||||
|
||||
/// Device has a value; the profile sets a different one.
|
||||
change,
|
||||
}
|
||||
|
||||
class DiffRow {
|
||||
const DiffRow({
|
||||
required this.label,
|
||||
required this.oldValue,
|
||||
required this.newValue,
|
||||
required this.kind,
|
||||
required this.danger,
|
||||
required this.secret,
|
||||
});
|
||||
|
||||
/// Firmware key (flat) or `broker N.<field>`, never a value, safe to show.
|
||||
final String label;
|
||||
|
||||
/// Current on-device value, or null if unknown/unreadable (e.g. write-only
|
||||
/// `wifi.pwd`) or absent.
|
||||
final String? oldValue;
|
||||
final String newValue;
|
||||
final DiffKind kind;
|
||||
|
||||
/// In the credential/identity danger set, routed to the red gate.
|
||||
final bool danger;
|
||||
|
||||
/// A true secret (password / wifi.pwd), the UI must mask both values.
|
||||
final bool secret;
|
||||
}
|
||||
|
||||
class ProfileDiff {
|
||||
const ProfileDiff(this.rows);
|
||||
final List<DiffRow> rows;
|
||||
|
||||
bool get isEmpty => rows.isEmpty;
|
||||
List<DiffRow> get dangerRows => rows.where((r) => r.danger).toList();
|
||||
List<DiffRow> get safeRows => rows.where((r) => !r.danger).toList();
|
||||
bool get hasDanger => rows.any((r) => r.danger);
|
||||
}
|
||||
|
||||
/// Broker sub-keys + flat keys that hold true secrets (mask both values).
|
||||
const Set<String> _secretBrokerFields = {ConfigKeys.brokerPassword};
|
||||
const Set<String> _secretFlatKeys = {ConfigKeys.wifiPassword};
|
||||
|
||||
/// Build the diff. [currentFlat] maps a flat key → current value (null =
|
||||
/// unknown/absent). [currentBroker] maps slot → (subkey → current value).
|
||||
/// Rows where the new value equals the current value are dropped (no-op).
|
||||
ProfileDiff buildProfileDiff(
|
||||
ProfileWrites writes, {
|
||||
required Map<String, String?> currentFlat,
|
||||
required Map<int, Map<String, String?>> currentBroker,
|
||||
}) {
|
||||
final rows = <DiffRow>[];
|
||||
|
||||
for (final f in writes.flats) {
|
||||
final current = currentFlat[f.key];
|
||||
if (current == f.value) continue; // unchanged
|
||||
rows.add(
|
||||
DiffRow(
|
||||
label: f.key,
|
||||
oldValue: current,
|
||||
newValue: f.value,
|
||||
kind: (current == null || current.isEmpty)
|
||||
? DiffKind.add
|
||||
: DiffKind.change,
|
||||
danger: f.danger,
|
||||
secret: _secretFlatKeys.contains(f.key),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final b in writes.brokers) {
|
||||
final cur = currentBroker[b.slot] ?? const {};
|
||||
for (final entry in b.fields.entries) {
|
||||
final current = cur[entry.key];
|
||||
if (current == entry.value) continue; // unchanged
|
||||
rows.add(
|
||||
DiffRow(
|
||||
label: 'broker ${b.slot}.${entry.key}',
|
||||
oldValue: current,
|
||||
newValue: entry.value,
|
||||
kind: (current == null || current.isEmpty)
|
||||
? DiffKind.add
|
||||
: DiffKind.change,
|
||||
danger: b.dangerFields.contains(entry.key),
|
||||
secret: _secretBrokerFields.contains(entry.key),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Broker enabled is not a profile field (#456), never diffed/applied.
|
||||
}
|
||||
|
||||
return ProfileDiff(rows);
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
|
||||
/// Thrown when a config-profile document is not valid (#403).
|
||||
///
|
||||
/// Message is user-facing: the import flow shows it verbatim, so it names the
|
||||
/// offending key/value (with its section) rather than a stack position.
|
||||
class ConfigProfileFormatException implements Exception {
|
||||
const ConfigProfileFormatException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ConfigProfileFormatException: $message';
|
||||
}
|
||||
|
||||
/// Parse a YAML config profile into a [ConfigProfile] (#402 model).
|
||||
///
|
||||
/// Strict by design, profiles are untrusted input (#139 trust note), so an
|
||||
/// unknown key, a wrong type, or an out-of-range value is an error, not a silent
|
||||
/// skip. Only keys present in the document appear in the model; everything else
|
||||
/// stays null so the apply engines touch only what the profile sets.
|
||||
///
|
||||
/// Expected shape (v2, capability sections, #456):
|
||||
/// ```yaml
|
||||
/// schema_version: 2
|
||||
/// name: "US wide-area" # optional label, not applied
|
||||
/// wifi: { ssid: "...", password: "...", enabled: true }
|
||||
/// mqtt:
|
||||
/// region: "IAD" # -> mqtt.iata
|
||||
/// status_interval: 60
|
||||
/// brokers:
|
||||
/// - slot: 0
|
||||
/// enabled: true
|
||||
/// url: "..."
|
||||
/// port: 8883
|
||||
/// transport: tls # tcp | tls | wss
|
||||
/// auth_type: basic # none | basic | jwt
|
||||
/// username: "..."
|
||||
/// ...
|
||||
/// ```
|
||||
ConfigProfile parseConfigProfile(String source) {
|
||||
final dynamic doc;
|
||||
try {
|
||||
doc = loadYaml(source);
|
||||
} on YamlException catch (e) {
|
||||
throw ConfigProfileFormatException('Not valid YAML: ${e.message}');
|
||||
}
|
||||
|
||||
const root = 'document root';
|
||||
final map = _asMap(doc, root);
|
||||
|
||||
final version = _requireInt(map, 'schema_version', root);
|
||||
if (version > kConfigProfileSchemaVersion) {
|
||||
throw ConfigProfileFormatException(
|
||||
'Profile schema_version $version is newer than this app supports '
|
||||
'($kConfigProfileSchemaVersion). Update the app.',
|
||||
);
|
||||
}
|
||||
if (version < 2) {
|
||||
throw ConfigProfileFormatException(
|
||||
'Profile schema_version $version uses the old flat layout. Re-export it '
|
||||
'in the sectioned v2 format (wifi:/mqtt:).',
|
||||
);
|
||||
}
|
||||
|
||||
_rejectUnknownKeys(map, const {
|
||||
'schema_version',
|
||||
'name',
|
||||
'wifi',
|
||||
'mqtt',
|
||||
}, root);
|
||||
|
||||
return ConfigProfile(
|
||||
schemaVersion: version,
|
||||
name: _optString(map, 'name', root),
|
||||
wifi: _parseWifi(map['wifi']),
|
||||
mqtt: _parseMqtt(map['mqtt']),
|
||||
);
|
||||
}
|
||||
|
||||
MqttSection? _parseMqtt(dynamic node) {
|
||||
if (node == null) return null;
|
||||
const ctx = 'mqtt';
|
||||
final map = _asMap(node, ctx);
|
||||
_rejectUnknownKeys(map, const {'region', 'status_interval', 'brokers'}, ctx);
|
||||
return MqttSection(
|
||||
regionIata: _optString(map, 'region', ctx),
|
||||
statusInterval: _optUint(map, 'status_interval', ctx),
|
||||
brokers: _parseBrokers(map['brokers']),
|
||||
);
|
||||
}
|
||||
|
||||
WifiConfig? _parseWifi(dynamic node) {
|
||||
if (node == null) return null;
|
||||
const ctx = 'wifi';
|
||||
final map = _asMap(node, ctx);
|
||||
_rejectUnknownKeys(map, const {'ssid', 'password', 'enabled'}, ctx);
|
||||
return WifiConfig(
|
||||
ssid: _optString(map, 'ssid', ctx),
|
||||
password: _optString(map, 'password', ctx),
|
||||
enabled: _optBool(map, 'enabled', ctx),
|
||||
);
|
||||
}
|
||||
|
||||
List<BrokerConfig> _parseBrokers(dynamic node) {
|
||||
if (node == null) return const [];
|
||||
if (node is! YamlList) {
|
||||
throw const ConfigProfileFormatException('"brokers" must be a list');
|
||||
}
|
||||
final seenSlots = <int>{};
|
||||
final brokers = <BrokerConfig>[];
|
||||
for (var i = 0; i < node.length; i++) {
|
||||
final ctx = 'brokers[$i]';
|
||||
final map = _asMap(node[i], ctx);
|
||||
_rejectUnknownKeys(map, const {
|
||||
'slot',
|
||||
'url',
|
||||
'port',
|
||||
'transport',
|
||||
'auth_type',
|
||||
'username',
|
||||
'password',
|
||||
'jwt_token',
|
||||
'jwt_aud',
|
||||
'jwt_refresh',
|
||||
'jwt_owner',
|
||||
'jwt_email',
|
||||
'ca_cert',
|
||||
'topic_prefix',
|
||||
'iata_override',
|
||||
}, ctx);
|
||||
|
||||
final slot = _requireInt(map, 'slot', ctx);
|
||||
if (slot < 0 || slot >= kMaxBrokerSlots) {
|
||||
throw ConfigProfileFormatException(
|
||||
'$ctx.slot must be 0..${kMaxBrokerSlots - 1}, got $slot',
|
||||
);
|
||||
}
|
||||
if (!seenSlots.add(slot)) {
|
||||
throw ConfigProfileFormatException('duplicate broker slot $slot');
|
||||
}
|
||||
|
||||
final port = _optUint(map, 'port', ctx);
|
||||
if (port != null && (port < 1 || port > 65535)) {
|
||||
throw ConfigProfileFormatException(
|
||||
'$ctx.port must be 1..65535, got $port',
|
||||
);
|
||||
}
|
||||
|
||||
brokers.add(
|
||||
BrokerConfig(
|
||||
slot: slot,
|
||||
url: _optString(map, 'url', ctx),
|
||||
port: port,
|
||||
transport: _optEnum(
|
||||
map,
|
||||
'transport',
|
||||
ctx,
|
||||
MqttTransport.fromWire,
|
||||
'tcp/tls/wss',
|
||||
),
|
||||
authType: _optEnum(
|
||||
map,
|
||||
'auth_type',
|
||||
ctx,
|
||||
MqttAuthType.fromWire,
|
||||
'none/basic/jwt',
|
||||
),
|
||||
username: _optString(map, 'username', ctx),
|
||||
password: _optString(map, 'password', ctx),
|
||||
jwtToken: _optString(map, 'jwt_token', ctx),
|
||||
jwtAudience: _optString(map, 'jwt_aud', ctx),
|
||||
jwtRefresh: _optUint(map, 'jwt_refresh', ctx),
|
||||
jwtOwner: _optString(map, 'jwt_owner', ctx),
|
||||
jwtEmail: _optString(map, 'jwt_email', ctx),
|
||||
caCert: _optString(map, 'ca_cert', ctx),
|
||||
topicPrefix: _optString(map, 'topic_prefix', ctx),
|
||||
iataOverride: _optString(map, 'iata_override', ctx),
|
||||
),
|
||||
);
|
||||
}
|
||||
return brokers;
|
||||
}
|
||||
|
||||
// --- typed accessors (all name their section for user-facing errors) --------
|
||||
|
||||
Map _asMap(dynamic node, String what) {
|
||||
if (node is Map) return node;
|
||||
throw ConfigProfileFormatException('$what must be a mapping');
|
||||
}
|
||||
|
||||
void _rejectUnknownKeys(Map map, Set<String> allowed, String what) {
|
||||
for (final key in map.keys) {
|
||||
if (!allowed.contains(key)) {
|
||||
throw ConfigProfileFormatException('unknown key "$key" in $what');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _requireInt(Map map, String key, String ctx) {
|
||||
final v = map[key];
|
||||
if (v == null) {
|
||||
throw ConfigProfileFormatException('$ctx is missing required "$key"');
|
||||
}
|
||||
if (v is! int) {
|
||||
throw ConfigProfileFormatException('$ctx."$key" must be an integer');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
String? _optString(Map map, String key, String ctx) {
|
||||
final v = map[key];
|
||||
if (v == null) return null;
|
||||
if (v is! String) {
|
||||
throw ConfigProfileFormatException('$ctx."$key" must be a string');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Optional non-negative integer (durations, ports, counts). Rejects negatives
|
||||
/// since every integer field in a profile is a count/port/interval.
|
||||
int? _optUint(Map map, String key, String ctx) {
|
||||
final v = map[key];
|
||||
if (v == null) return null;
|
||||
if (v is! int) {
|
||||
throw ConfigProfileFormatException('$ctx."$key" must be an integer');
|
||||
}
|
||||
if (v < 0) {
|
||||
throw ConfigProfileFormatException('$ctx."$key" must not be negative');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
bool? _optBool(Map map, String key, String ctx) {
|
||||
final v = map[key];
|
||||
if (v == null) return null;
|
||||
if (v is! bool) {
|
||||
throw ConfigProfileFormatException('$ctx."$key" must be true or false');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
T? _optEnum<T>(
|
||||
Map map,
|
||||
String key,
|
||||
String ctx,
|
||||
T? Function(String?) fromWire,
|
||||
String allowed,
|
||||
) {
|
||||
final v = map[key];
|
||||
if (v == null) return null;
|
||||
if (v is! String) {
|
||||
throw ConfigProfileFormatException(
|
||||
'$ctx."$key" must be a string ($allowed)',
|
||||
);
|
||||
}
|
||||
final parsed = fromWire(v);
|
||||
if (parsed == null) {
|
||||
throw ConfigProfileFormatException(
|
||||
'$ctx."$key" must be one of $allowed, got "$v"',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
import '../models/config_profile.dart';
|
||||
|
||||
/// Enumerates the device writes a [ConfigProfile] implies (#405), device-agnostic
|
||||
/// so observer / repeater / companion executors share the same rules:
|
||||
///
|
||||
/// - **Skip null or empty**, a profile only touches keys it actually sets; a
|
||||
/// blank never clobbers a configured value. Clearing is a separate explicit op.
|
||||
/// - **Skip `jwt_token`**, it's live-minted by firmware at connect, never config.
|
||||
/// - **Skip broker `enabled`** (#456), enabling a broker is the operator's
|
||||
/// runtime decision, not profile config; apply preserves the current state.
|
||||
/// - **Danger fields** (owner/identity/credentials) are flagged so the preview
|
||||
/// (#406) can gate them: broker `username`/`password`/`jwt_owner`/`jwt_email`,
|
||||
/// and global `wifi.pwd`.
|
||||
|
||||
/// Broker sub-keys whose set/change/wipe requires the preview's red danger gate.
|
||||
const Set<String> kDangerBrokerFields = {
|
||||
ConfigKeys.brokerUsername,
|
||||
ConfigKeys.brokerPassword,
|
||||
ConfigKeys.brokerJwtOwner,
|
||||
ConfigKeys.brokerJwtEmail,
|
||||
};
|
||||
|
||||
/// Global flat keys requiring the danger gate.
|
||||
const Set<String> kDangerFlatKeys = {ConfigKeys.wifiPassword};
|
||||
|
||||
/// A single flat (non-broker) write: `wifi.*`, `mqtt.iata`, `mqtt.status_interval`.
|
||||
class FlatWrite {
|
||||
const FlatWrite(this.key, this.value, {this.danger = false});
|
||||
final String key;
|
||||
final String value;
|
||||
final bool danger;
|
||||
}
|
||||
|
||||
/// The writes for one broker slot. [fields] excludes `jwt_token` (never written)
|
||||
/// and the broker `enabled` flag (#456: not a profile field, apply preserves
|
||||
/// the device's current enabled state).
|
||||
class BrokerWrites {
|
||||
const BrokerWrites({
|
||||
required this.slot,
|
||||
required this.fields,
|
||||
required this.dangerFields,
|
||||
});
|
||||
final int slot;
|
||||
final Map<String, String> fields;
|
||||
final Set<String> dangerFields;
|
||||
}
|
||||
|
||||
/// The full set of writes a profile implies.
|
||||
class ProfileWrites {
|
||||
const ProfileWrites({required this.flats, required this.brokers});
|
||||
|
||||
/// Global flats, already ordered so `wifi.enabled` (if present) comes last.
|
||||
final List<FlatWrite> flats;
|
||||
final List<BrokerWrites> brokers;
|
||||
|
||||
bool get isEmpty => flats.isEmpty && brokers.isEmpty;
|
||||
|
||||
/// True if any write (flat or broker) is a danger-gated field.
|
||||
bool get hasDanger =>
|
||||
flats.any((f) => f.danger) ||
|
||||
brokers.any((b) => b.dangerFields.isNotEmpty);
|
||||
}
|
||||
|
||||
/// Partition writes into (safe, danger) so the preview's two buttons each apply
|
||||
/// their own set: the normal Apply writes safe changes; the red gate writes the
|
||||
/// credential/identity ones. A broker with both is split across both.
|
||||
({ProfileWrites safe, ProfileWrites danger}) splitProfileWrites(
|
||||
ProfileWrites w,
|
||||
) {
|
||||
final safeFlats = w.flats.where((f) => !f.danger).toList();
|
||||
final dangerFlats = w.flats.where((f) => f.danger).toList();
|
||||
|
||||
final safeBrokers = <BrokerWrites>[];
|
||||
final dangerBrokers = <BrokerWrites>[];
|
||||
for (final b in w.brokers) {
|
||||
final safeFields = <String, String>{
|
||||
for (final e in b.fields.entries)
|
||||
if (!b.dangerFields.contains(e.key)) e.key: e.value,
|
||||
};
|
||||
final dangerFields = <String, String>{
|
||||
for (final e in b.fields.entries)
|
||||
if (b.dangerFields.contains(e.key)) e.key: e.value,
|
||||
};
|
||||
if (safeFields.isNotEmpty) {
|
||||
safeBrokers.add(
|
||||
BrokerWrites(slot: b.slot, fields: safeFields, dangerFields: const {}),
|
||||
);
|
||||
}
|
||||
if (dangerFields.isNotEmpty) {
|
||||
dangerBrokers.add(
|
||||
BrokerWrites(
|
||||
slot: b.slot,
|
||||
fields: dangerFields,
|
||||
dangerFields: dangerFields.keys.toSet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
safe: ProfileWrites(flats: safeFlats, brokers: safeBrokers),
|
||||
danger: ProfileWrites(flats: dangerFlats, brokers: dangerBrokers),
|
||||
);
|
||||
}
|
||||
|
||||
bool _blank(String? v) => v == null || v.isEmpty;
|
||||
|
||||
ProfileWrites enumerateProfileWrites(ConfigProfile p) {
|
||||
final flats = <FlatWrite>[];
|
||||
|
||||
// WiFi, ssid/pwd first, enabled last (activation guard).
|
||||
final wifi = p.wifi;
|
||||
if (wifi != null) {
|
||||
if (!_blank(wifi.ssid)) {
|
||||
flats.add(FlatWrite(ConfigKeys.wifiSsid, wifi.ssid!));
|
||||
}
|
||||
if (!_blank(wifi.password)) {
|
||||
flats.add(
|
||||
FlatWrite(ConfigKeys.wifiPassword, wifi.password!, danger: true),
|
||||
);
|
||||
}
|
||||
if (wifi.enabled != null) {
|
||||
flats.add(FlatWrite(ConfigKeys.wifiEnabled, wifi.enabled! ? '1' : '0'));
|
||||
}
|
||||
}
|
||||
|
||||
final mqtt = p.mqtt;
|
||||
if (mqtt != null && !_blank(mqtt.regionIata)) {
|
||||
flats.add(FlatWrite(ConfigKeys.mqttIata, mqtt.regionIata!));
|
||||
}
|
||||
if (mqtt != null && mqtt.statusInterval != null) {
|
||||
flats.add(
|
||||
FlatWrite(ConfigKeys.mqttStatusInterval, '${mqtt.statusInterval}'),
|
||||
);
|
||||
}
|
||||
|
||||
final brokers = <BrokerWrites>[];
|
||||
for (final b in (mqtt?.brokers ?? const <BrokerConfig>[])) {
|
||||
final fields = <String, String>{};
|
||||
void put(String key, String? value) {
|
||||
if (!_blank(value)) fields[key] = value!;
|
||||
}
|
||||
|
||||
put(ConfigKeys.brokerUrl, b.url);
|
||||
if (b.port != null) fields[ConfigKeys.brokerPort] = '${b.port}';
|
||||
if (b.transport != null) {
|
||||
fields[ConfigKeys.brokerTransport] = b.transport!.wire;
|
||||
}
|
||||
if (b.authType != null) {
|
||||
fields[ConfigKeys.brokerAuthType] = b.authType!.wire;
|
||||
}
|
||||
put(ConfigKeys.brokerUsername, b.username);
|
||||
put(ConfigKeys.brokerPassword, b.password);
|
||||
// jwt_token deliberately omitted, live-minted at connect, never config.
|
||||
put(ConfigKeys.brokerJwtAudience, b.jwtAudience);
|
||||
if (b.jwtRefresh != null) {
|
||||
fields[ConfigKeys.brokerJwtRefresh] = '${b.jwtRefresh}';
|
||||
}
|
||||
put(ConfigKeys.brokerJwtOwner, b.jwtOwner);
|
||||
put(ConfigKeys.brokerJwtEmail, b.jwtEmail);
|
||||
put(ConfigKeys.brokerCaCert, b.caCert);
|
||||
put(ConfigKeys.brokerTopicPrefix, b.topicPrefix);
|
||||
put(ConfigKeys.brokerIataOverride, b.iataOverride);
|
||||
|
||||
if (fields.isEmpty) continue; // nothing to write
|
||||
|
||||
brokers.add(
|
||||
BrokerWrites(
|
||||
slot: b.slot,
|
||||
fields: fields,
|
||||
dangerFields: fields.keys.where(kDangerBrokerFields.contains).toSet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ProfileWrites(flats: flats, brokers: brokers);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
/// Crockford Base32, as used by the PocketMesh / MeshCore One reaction format.
|
||||
///
|
||||
/// Encode-only: a reaction hash is compared as a string and never decoded back
|
||||
/// to bytes. The alphabet omits i, l, o and u to avoid visual ambiguity.
|
||||
class CrockfordBase32 {
|
||||
static const String alphabet = '0123456789abcdefghjkmnpqrstvwxyz';
|
||||
|
||||
/// Encode exactly 5 bytes (40 bits) as 8 characters, most significant first.
|
||||
///
|
||||
/// Accumulates at most 12 bits at a time rather than packing all 40 into one
|
||||
/// int: bitwise operators are 32-bit on the web target, so a 40-bit shift
|
||||
/// would silently truncate there.
|
||||
static String encode5(List<int> bytes) {
|
||||
if (bytes.length != 5) {
|
||||
throw ArgumentError.value(
|
||||
bytes.length,
|
||||
'bytes',
|
||||
'Crockford Base32 encode5 needs exactly 5 bytes',
|
||||
);
|
||||
}
|
||||
final out = StringBuffer();
|
||||
var buffer = 0;
|
||||
var bits = 0;
|
||||
for (final byte in bytes) {
|
||||
buffer = (buffer << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.write(alphabet[(buffer >> bits) & 0x1F]);
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// The lowercase canonical form of an 8-character hash, or null if [text] is
|
||||
/// not valid Crockford Base32.
|
||||
///
|
||||
/// Resolves the ambiguity aliases the format defines on input (O to 0, I and
|
||||
/// L to 1, either case) so a sender that emits them still matches. `u` has no
|
||||
/// alias and is rejected.
|
||||
static String? normalize8(String text) {
|
||||
if (text.length != 8) return null;
|
||||
final out = StringBuffer();
|
||||
for (var i = 0; i < 8; i++) {
|
||||
final lower = text[i].toLowerCase();
|
||||
final resolved = switch (lower) {
|
||||
'o' => '0',
|
||||
'i' || 'l' => '1',
|
||||
_ => lower,
|
||||
};
|
||||
if (!alphabet.contains(resolved)) return null;
|
||||
out.write(resolved);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import 'reaction_helper.dart';
|
||||
|
||||
class _Pending {
|
||||
final String scopeKey;
|
||||
final ReactionInfo info;
|
||||
final String reactingSender;
|
||||
final DateTime queuedAt;
|
||||
|
||||
const _Pending(this.scopeKey, this.info, this.reactingSender, this.queuedAt);
|
||||
}
|
||||
|
||||
/// Reactions that arrived before the message they point at.
|
||||
///
|
||||
/// Out-of-order delivery is normal on a mesh, so a reaction with no local
|
||||
/// target is not junk, it is early. Before this existed the connector consumed
|
||||
/// such a reaction and dropped it with no badge, no message and no log, which
|
||||
/// is indistinguishable from nobody having reacted (GH #382).
|
||||
///
|
||||
/// Entries are bounded and expire. A drop is logged at warn so it surfaces in
|
||||
/// the in-app log and the file log rather than vanishing (SAFELANE 6). Whether
|
||||
/// dropping is the right end state is still open; the ratio of late matches to
|
||||
/// expiries in the log is the evidence for revisiting it.
|
||||
class PendingReactions {
|
||||
static const int maxEntries = 50;
|
||||
static const Duration ttl = Duration(minutes: 15);
|
||||
|
||||
final List<_Pending> _entries = [];
|
||||
|
||||
int get length => _entries.length;
|
||||
|
||||
void add(
|
||||
String scopeKey,
|
||||
ReactionInfo info,
|
||||
String reactingSender,
|
||||
DateTime now,
|
||||
) {
|
||||
expire(now);
|
||||
_entries.add(_Pending(scopeKey, info, reactingSender, now));
|
||||
if (_entries.length > maxEntries) {
|
||||
_drop(_entries.removeAt(0), now, 'queue full');
|
||||
}
|
||||
appLogger.info(
|
||||
'Queued reaction ${info.emoji} from $reactingSender for unseen target '
|
||||
'${info.targetHash} in $scopeKey (${_entries.length}/$maxEntries pending)',
|
||||
tag: 'Reactions',
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-attempt every live entry for [scopeKey]. [apply] receives the queued
|
||||
/// reaction and the name of whoever sent it, and reports whether it found its
|
||||
/// target; matched entries are removed, the rest stay.
|
||||
void retry(
|
||||
String scopeKey,
|
||||
bool Function(ReactionInfo info, String reactingSender) apply,
|
||||
DateTime now,
|
||||
) {
|
||||
expire(now);
|
||||
_entries.removeWhere((entry) {
|
||||
if (entry.scopeKey != scopeKey) return false;
|
||||
if (!apply(entry.info, entry.reactingSender)) return false;
|
||||
appLogger.info(
|
||||
'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} '
|
||||
'to ${entry.info.targetHash} in $scopeKey after '
|
||||
'${now.difference(entry.queuedAt).inSeconds}s',
|
||||
tag: 'Reactions',
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void expire(DateTime now) {
|
||||
_entries.removeWhere((entry) {
|
||||
if (now.difference(entry.queuedAt) < ttl) return false;
|
||||
_drop(entry, now, 'expired after ${ttl.inMinutes}m');
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
void _drop(_Pending entry, DateTime now, String why) {
|
||||
appLogger.warn(
|
||||
'Dropping unmatched reaction ${entry.info.emoji} from '
|
||||
'${entry.reactingSender} for target ${entry.info.targetHash} in '
|
||||
'${entry.scopeKey} ($why, age ${now.difference(entry.queuedAt).inSeconds}s). '
|
||||
'The message it points at was never seen locally.',
|
||||
tag: 'Reactions',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'crockford_base32.dart';
|
||||
|
||||
/// A reaction in the PocketMesh / MeshCore One wire format.
|
||||
///
|
||||
/// Channel form: `{emoji}@[{targetSenderName}]\n{hash}`
|
||||
/// Direct form: `{emoji}\n{hash}`
|
||||
///
|
||||
/// The hash identifies the target message as sha256 over the target's body
|
||||
/// text (UTF-8) followed by its sender-claimed timestamp as a little-endian
|
||||
/// uint32 of epoch seconds, truncated to the first 5 bytes and encoded as 8
|
||||
/// Crockford Base32 characters. The channel `SenderName: ` prefix is NOT part
|
||||
/// of the hashed text, which is why the sender travels in `@[...]` instead.
|
||||
///
|
||||
/// Confirmed against a live capture from a MeshCore One peer, see GH #378.
|
||||
/// Receive-only: Offband still sends its own `r:hhhh:ii` format (GH #379).
|
||||
class PocketMeshReaction {
|
||||
final String emoji;
|
||||
|
||||
/// The target message's sender, present in the channel form only. In a direct
|
||||
/// conversation the sender is implicit.
|
||||
final String? targetSenderName;
|
||||
|
||||
/// 8 characters, lowercase canonical form.
|
||||
final String targetHash;
|
||||
|
||||
const PocketMeshReaction({
|
||||
required this.emoji,
|
||||
required this.targetHash,
|
||||
this.targetSenderName,
|
||||
});
|
||||
|
||||
static String computeHash(String bodyText, int timestampSeconds) {
|
||||
final body = utf8.encode(bodyText);
|
||||
final input = Uint8List(body.length + 4);
|
||||
input.setRange(0, body.length, body);
|
||||
// Division rather than shifts: `>>` is signed 32-bit on the web target and
|
||||
// would misencode any timestamp past 2038.
|
||||
final seconds = timestampSeconds % 4294967296;
|
||||
input[body.length] = seconds % 256;
|
||||
input[body.length + 1] = (seconds ~/ 256) % 256;
|
||||
input[body.length + 2] = (seconds ~/ 65536) % 256;
|
||||
input[body.length + 3] = (seconds ~/ 16777216) % 256;
|
||||
return CrockfordBase32.encode5(sha256.convert(input).bytes.sublist(0, 5));
|
||||
}
|
||||
|
||||
/// Parse [text] as a reaction, or null if it is an ordinary message.
|
||||
///
|
||||
/// Mirrors the reference parser: the last line must be exactly 8 valid
|
||||
/// Crockford Base32 characters, and the part before it must start with an
|
||||
/// emoji. Both checks matter, since anything accepted here is swallowed
|
||||
/// instead of being shown as a message.
|
||||
static PocketMeshReaction? parse(String text, {required bool isDm}) {
|
||||
final newline = text.lastIndexOf('\n');
|
||||
if (newline < 0) return null;
|
||||
|
||||
final hash = CrockfordBase32.normalize8(text.substring(newline + 1));
|
||||
if (hash == null) return null;
|
||||
|
||||
final head = text.substring(0, newline);
|
||||
|
||||
if (isDm) {
|
||||
if (head.contains('@[')) return null;
|
||||
return _build(emoji: head, sender: null, hash: hash);
|
||||
}
|
||||
|
||||
final bracket = head.indexOf('@[');
|
||||
if (bracket < 0) return null;
|
||||
final afterBracket = head.substring(bracket + 2);
|
||||
if (!afterBracket.endsWith(']')) return null;
|
||||
final sender = afterBracket.substring(0, afterBracket.length - 1);
|
||||
if (sender.isEmpty) return null;
|
||||
|
||||
return _build(
|
||||
emoji: head.substring(0, bracket),
|
||||
sender: sender,
|
||||
hash: hash,
|
||||
);
|
||||
}
|
||||
|
||||
static PocketMeshReaction? _build({
|
||||
required String emoji,
|
||||
required String? sender,
|
||||
required String hash,
|
||||
}) {
|
||||
if (!_isReactionEmoji(emoji)) return null;
|
||||
return PocketMeshReaction(
|
||||
emoji: emoji,
|
||||
targetSenderName: sender,
|
||||
targetHash: hash,
|
||||
);
|
||||
}
|
||||
|
||||
/// A reaction is one emoji, possibly with a variation selector, a skin-tone
|
||||
/// modifier or ZWJ joins. Eight runes is well clear of the longest such
|
||||
/// sequence and nowhere near a sentence.
|
||||
///
|
||||
/// This cap is the guard that matters: without it, any multi-line message
|
||||
/// starting with a symbol and ending in eight Crockford characters would be
|
||||
/// swallowed whole and shown as the reaction "emoji".
|
||||
static const int _maxEmojiRunes = 8;
|
||||
|
||||
/// Deliberately conservative: a missed emoji only means the reaction renders
|
||||
/// as text, which is the behaviour we have today, while a false positive
|
||||
/// would swallow a real message.
|
||||
///
|
||||
/// Arrows (U+2190-U+21FF, U+2934-U+2935) are excluded on purpose even though
|
||||
/// they carry the Unicode Emoji property. They are ordinary punctuation in
|
||||
/// prose, and a live capture from this mesh contained U+2192 mid-sentence in
|
||||
/// a normal channel message.
|
||||
static const List<List<int>> _emojiRanges = [
|
||||
[0x1F000, 0x1FAFF],
|
||||
[0x2600, 0x27BF],
|
||||
[0x2B00, 0x2BFF],
|
||||
[0x3030, 0x3030],
|
||||
[0x303D, 0x303D],
|
||||
[0x3297, 0x3299],
|
||||
];
|
||||
|
||||
static bool _isReactionEmoji(String text) {
|
||||
final runes = text.runes;
|
||||
if (runes.isEmpty || runes.length > _maxEmojiRunes) return false;
|
||||
final first = runes.first;
|
||||
for (final range in _emojiRanges) {
|
||||
if (first >= range[0] && first <= range[1]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// Catalog manifest model (#404): the `profiles.json` a source publishes.
|
||||
//
|
||||
// Schema is documented in the `OffbandMesh/config-profiles` repo (SCHEMA.md).
|
||||
// Parsing is resilient, a single malformed entry is skipped rather than
|
||||
// failing the whole catalog, but a manifest that isn't JSON, or declares a
|
||||
// newer `manifest_version` than we support, is rejected outright.
|
||||
|
||||
/// Highest manifest_version this build understands.
|
||||
const int kCatalogManifestVersion = 1;
|
||||
|
||||
/// Thrown when a manifest cannot be used at all (bad JSON / unsupported version).
|
||||
class ConfigCatalogFormatException implements Exception {
|
||||
const ConfigCatalogFormatException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ConfigCatalogFormatException: $message';
|
||||
}
|
||||
|
||||
enum CatalogStatus {
|
||||
published,
|
||||
retired,
|
||||
|
||||
/// A status string this build doesn't recognize. Treated as not-published so
|
||||
/// an unknown future status never accidentally surfaces in the picker.
|
||||
unknown;
|
||||
|
||||
static CatalogStatus fromWire(String? v) {
|
||||
switch (v) {
|
||||
case 'published':
|
||||
return CatalogStatus.published;
|
||||
case 'retired':
|
||||
return CatalogStatus.retired;
|
||||
default:
|
||||
return CatalogStatus.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One catalog entry. [name] and [url] are required; the rest are hints.
|
||||
class CatalogEntry {
|
||||
const CatalogEntry({
|
||||
required this.name,
|
||||
required this.url,
|
||||
this.description,
|
||||
this.region,
|
||||
this.schemaVersion,
|
||||
this.status = CatalogStatus.published,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String url;
|
||||
final String? description;
|
||||
final String? region;
|
||||
final int? schemaVersion;
|
||||
final CatalogStatus status;
|
||||
}
|
||||
|
||||
class ConfigCatalog {
|
||||
const ConfigCatalog({
|
||||
required this.manifestVersion,
|
||||
required this.entries,
|
||||
this.skippedEntries = 0,
|
||||
});
|
||||
|
||||
final int manifestVersion;
|
||||
final List<CatalogEntry> entries;
|
||||
|
||||
/// Count of malformed entries skipped during parse, surfaced so a partly-bad
|
||||
/// catalog doesn't look complete (no silent truncation).
|
||||
final int skippedEntries;
|
||||
|
||||
/// Entries the app should offer in the picker.
|
||||
List<CatalogEntry> get published =>
|
||||
entries.where((e) => e.status == CatalogStatus.published).toList();
|
||||
}
|
||||
|
||||
/// Parse a `profiles.json` manifest. Throws [ConfigCatalogFormatException] for a
|
||||
/// manifest that can't be used; skips individual malformed entries.
|
||||
ConfigCatalog parseCatalog(String source) {
|
||||
final dynamic doc;
|
||||
try {
|
||||
doc = jsonDecode(source);
|
||||
} on FormatException catch (e) {
|
||||
throw ConfigCatalogFormatException('Not valid JSON: ${e.message}');
|
||||
}
|
||||
if (doc is! Map) {
|
||||
throw const ConfigCatalogFormatException('manifest must be a JSON object');
|
||||
}
|
||||
|
||||
final version = doc['manifest_version'];
|
||||
if (version is! int) {
|
||||
throw const ConfigCatalogFormatException(
|
||||
'manifest_version is required and must be an integer',
|
||||
);
|
||||
}
|
||||
if (version > kCatalogManifestVersion) {
|
||||
throw ConfigCatalogFormatException(
|
||||
'manifest_version $version is newer than this app supports '
|
||||
'($kCatalogManifestVersion). Update the app.',
|
||||
);
|
||||
}
|
||||
|
||||
final rawProfiles = doc['profiles'];
|
||||
if (rawProfiles != null && rawProfiles is! List) {
|
||||
throw const ConfigCatalogFormatException('"profiles" must be a list');
|
||||
}
|
||||
|
||||
final entries = <CatalogEntry>[];
|
||||
var skipped = 0;
|
||||
for (final raw in (rawProfiles as List? ?? const [])) {
|
||||
if (raw is! Map) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
final name = raw['name'];
|
||||
final url = raw['url'];
|
||||
if (name is! String || name.isEmpty || url is! String || url.isEmpty) {
|
||||
skipped++; // name + url are the minimum an entry must carry
|
||||
continue;
|
||||
}
|
||||
entries.add(
|
||||
CatalogEntry(
|
||||
name: name,
|
||||
url: url,
|
||||
description: raw['description'] is String
|
||||
? raw['description'] as String
|
||||
: null,
|
||||
region: raw['region'] is String ? raw['region'] as String : null,
|
||||
schemaVersion: raw['schema_version'] is int
|
||||
? raw['schema_version'] as int
|
||||
: null,
|
||||
// Absent status = published (author just omitted it). A present-but-
|
||||
// unrecognized status resolves to unknown and is hidden (forward-safe).
|
||||
status: raw.containsKey('status')
|
||||
? CatalogStatus.fromWire(
|
||||
raw['status'] is String ? raw['status'] as String : null,
|
||||
)
|
||||
: CatalogStatus.published,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ConfigCatalog(
|
||||
manifestVersion: version,
|
||||
entries: entries,
|
||||
skippedEntries: skipped,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
/// Importable device config-profile model (#402, feature #136).
|
||||
///
|
||||
/// A profile is a portable set of config values a user applies to a device
|
||||
/// (observer #139, repeater #137, companion #138) instead of baked defaults.
|
||||
/// This file defines only the schema and typed model, parsing (#403) and the
|
||||
/// per-device apply engines live elsewhere.
|
||||
///
|
||||
/// Every field is nullable: a profile carries only the keys it wants to set, so
|
||||
/// the apply engines can write field-at-a-time and leave everything else alone.
|
||||
///
|
||||
/// Key names and the wire encoding mirror the firmware config schema
|
||||
/// (`meshcore-firmware/.../wifi_observer/ConfigSchema.h`). transport and
|
||||
/// auth_type travel as their string names, not the NVS ordinals.
|
||||
library;
|
||||
|
||||
/// Bumped when the on-disk/YAML shape changes incompatibly. The parser (#403)
|
||||
/// rejects a profile whose declared version it does not understand.
|
||||
/// v2 (#456): capability sections (`wifi`, `mqtt`, …). v1 was the flat layout.
|
||||
const int kConfigProfileSchemaVersion = 2;
|
||||
|
||||
/// Number of broker slots the firmware exposes (`mqtt_b0`..`mqtt_b5`).
|
||||
const int kMaxBrokerSlots = 6;
|
||||
|
||||
/// MQTT transport, wire value is the name (`tcp`/`tls`/`wss`), not the ordinal.
|
||||
enum MqttTransport {
|
||||
tcp,
|
||||
tls,
|
||||
wss;
|
||||
|
||||
String get wire => name;
|
||||
|
||||
static MqttTransport? fromWire(String? v) {
|
||||
if (v == null) return null;
|
||||
for (final t in values) {
|
||||
if (t.name == v.toLowerCase()) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// MQTT auth type, wire value is the name (`none`/`basic`/`jwt`).
|
||||
enum MqttAuthType {
|
||||
none,
|
||||
basic,
|
||||
jwt;
|
||||
|
||||
String get wire => name;
|
||||
|
||||
static MqttAuthType? fromWire(String? v) {
|
||||
if (v == null) return null;
|
||||
for (final a in values) {
|
||||
if (a.name == v.toLowerCase()) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// WiFi credentials (`wifi.*`). [password] maps to the firmware's `wifi.pwd`
|
||||
/// and is write-only on the device (GET returns an error), so a profile can set
|
||||
/// it but never round-trips it back.
|
||||
class WifiConfig {
|
||||
const WifiConfig({this.ssid, this.password, this.enabled});
|
||||
|
||||
final String? ssid;
|
||||
final String? password;
|
||||
final bool? enabled;
|
||||
|
||||
bool get isEmpty => ssid == null && password == null && enabled == null;
|
||||
}
|
||||
|
||||
/// One broker slot (`mqtt.broker.<slot>.*`). Sensitive fields ([password],
|
||||
/// [jwtToken]) are included so a profile *can* carry them, but sharing a profile
|
||||
/// with secrets is a trust concern the import flow must surface (#139 trust note).
|
||||
class BrokerConfig {
|
||||
const BrokerConfig({
|
||||
required this.slot,
|
||||
this.url,
|
||||
this.port,
|
||||
this.transport,
|
||||
this.authType,
|
||||
this.username,
|
||||
this.password,
|
||||
this.jwtToken,
|
||||
this.jwtAudience,
|
||||
this.jwtRefresh,
|
||||
this.jwtOwner,
|
||||
this.jwtEmail,
|
||||
this.caCert,
|
||||
this.topicPrefix,
|
||||
this.iataOverride,
|
||||
});
|
||||
|
||||
/// 0-based slot index, `0 <= slot < kMaxBrokerSlots`.
|
||||
///
|
||||
/// Broker `enabled` is deliberately NOT a profile field (#456): enabling a
|
||||
/// broker is the operator's runtime decision (firmware ships slots disabled,
|
||||
/// opt-in per #262). A profile configures the connection; apply preserves the
|
||||
/// device's current enabled state.
|
||||
final int slot;
|
||||
final String? url;
|
||||
final int? port;
|
||||
final MqttTransport? transport;
|
||||
final MqttAuthType? authType;
|
||||
final String? username;
|
||||
final String? password;
|
||||
final String? jwtToken;
|
||||
final String? jwtAudience;
|
||||
final int? jwtRefresh;
|
||||
final String? jwtOwner;
|
||||
final String? jwtEmail;
|
||||
final String? caCert;
|
||||
final String? topicPrefix;
|
||||
final String? iataOverride;
|
||||
}
|
||||
|
||||
/// The `mqtt` section, observer/MQTT capability. Shared by any device running
|
||||
/// the observer role (observer, observer-repeater, observer-companion); never a
|
||||
/// plain companion or plain repeater (#456).
|
||||
class MqttSection {
|
||||
const MqttSection({
|
||||
this.regionIata,
|
||||
this.statusInterval,
|
||||
this.brokers = const [],
|
||||
});
|
||||
|
||||
/// `mqtt.iata`, the region/IATA code.
|
||||
final String? regionIata;
|
||||
|
||||
/// `mqtt.status_interval`, seconds between status publishes.
|
||||
final int? statusInterval;
|
||||
|
||||
/// Populated broker slots only (sparse; each carries its [BrokerConfig.slot]).
|
||||
final List<BrokerConfig> brokers;
|
||||
|
||||
bool get isEmpty =>
|
||||
regionIata == null && statusInterval == null && brokers.isEmpty;
|
||||
}
|
||||
|
||||
/// A complete importable config profile, a set of capability-scoped sections
|
||||
/// (#456). A device applies the sections it supports; the apply *mechanism* is
|
||||
/// per-device, the section *schema* is per-capability and shared. Future
|
||||
/// sections (`radio`, `repeater`, `companion`, `display`) slot in alongside.
|
||||
class ConfigProfile {
|
||||
const ConfigProfile({
|
||||
required this.schemaVersion,
|
||||
this.name,
|
||||
this.wifi,
|
||||
this.mqtt,
|
||||
});
|
||||
|
||||
final int schemaVersion;
|
||||
|
||||
/// Optional human label (not applied to the device).
|
||||
final String? name;
|
||||
|
||||
/// `wifi` section, any wifi-capable device.
|
||||
final WifiConfig? wifi;
|
||||
|
||||
/// `mqtt` section, observer/MQTT capability.
|
||||
final MqttSection? mqtt;
|
||||
}
|
||||
|
||||
/// Firmware config-key names. Callers (parser #403, apply engines) build keys
|
||||
/// from these rather than hard-coding strings, so a firmware rename lands in one
|
||||
/// place. Broker keys are `mqtt.broker.<slot>.<subkey>`.
|
||||
abstract final class ConfigKeys {
|
||||
static const String wifiPrefix = 'wifi.';
|
||||
static const String wifiSsid = 'wifi.ssid';
|
||||
static const String wifiPassword = 'wifi.pwd';
|
||||
static const String wifiEnabled = 'wifi.enabled';
|
||||
|
||||
static const String mqttIata = 'mqtt.iata';
|
||||
static const String mqttStatusInterval = 'mqtt.status_interval';
|
||||
|
||||
static const String brokerPrefix = 'mqtt.broker.';
|
||||
|
||||
// Broker sub-keys, appended after `mqtt.broker.<slot>.`.
|
||||
static const String brokerEnabled = 'enabled';
|
||||
static const String brokerUrl = 'url';
|
||||
static const String brokerPort = 'port';
|
||||
static const String brokerTransport = 'transport';
|
||||
static const String brokerAuthType = 'auth_type';
|
||||
static const String brokerUsername = 'username';
|
||||
static const String brokerPassword = 'password';
|
||||
static const String brokerJwtToken = 'jwt_token';
|
||||
static const String brokerJwtAudience = 'jwt_aud';
|
||||
static const String brokerJwtRefresh = 'jwt_refresh';
|
||||
static const String brokerJwtOwner = 'jwt_owner';
|
||||
static const String brokerJwtEmail = 'jwt_email';
|
||||
static const String brokerCaCert = 'ca_cert';
|
||||
static const String brokerTopicPrefix = 'topic_prefix';
|
||||
static const String brokerIataOverride = 'iata_override';
|
||||
|
||||
/// Full key for a broker sub-key, e.g. `mqtt.broker.2.url`.
|
||||
static String broker(int slot, String subKey) => '$brokerPrefix$slot.$subKey';
|
||||
}
|
||||
@ -0,0 +1,477 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/caplog_reassembler.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../utils/log_export.dart';
|
||||
|
||||
/// Serial-capture diagnostics screen (#430).
|
||||
///
|
||||
/// Controls the connected radio's serial-capture buffer over the 0xC4 companion
|
||||
/// command (enable / disable / erase / status) and downloads it as a shareable
|
||||
/// file. Support is gated on the device-info **capability bit** (`0x20` +
|
||||
/// `FIRMWARE_VER_CODE >= 17`), which is static and refreshes on reconnect, so
|
||||
/// the feature never latches "unsupported" after a reboot. Capture state is
|
||||
/// derived from the device STATUS, so an auto-resumed capture (post-reboot)
|
||||
/// shows STOP, not START.
|
||||
///
|
||||
/// Strings are English-only for now; localization is a follow-up (#427).
|
||||
class SerialCaptureScreen extends StatefulWidget {
|
||||
const SerialCaptureScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SerialCaptureScreen> createState() => _SerialCaptureScreenState();
|
||||
}
|
||||
|
||||
class _SerialCaptureScreenState extends State<SerialCaptureScreen> {
|
||||
/// Capture-window options in minutes for the timed flow; 0 = until stopped.
|
||||
static const List<int> _durations = [1, 5, 15, 30, 0];
|
||||
|
||||
MeshCoreConnector? _connector;
|
||||
CaplogDeviceStatus? _status;
|
||||
int _durationMinutes = 5;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
bool _wasConnected = false;
|
||||
|
||||
DateTime? _startedAt;
|
||||
int? _timedWindowMinutes; // set while a timed capture is running (for the UI)
|
||||
Timer? _tick;
|
||||
Timer? _autoStop;
|
||||
Timer? _statusPoll;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
_connector = c;
|
||||
c.addListener(_onConnectorChanged);
|
||||
_wasConnected = c.isConnected;
|
||||
if (c.isConnected && c.supportsOffbandCaplog) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_ensureStatusPolling();
|
||||
_refreshStatus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connector?.removeListener(_onConnectorChanged);
|
||||
_stopTimers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// React to connection transitions: on reconnect re-derive device state (no
|
||||
/// latch); on disconnect stop polling a dead link.
|
||||
void _onConnectorChanged() {
|
||||
final c = _connector;
|
||||
if (c == null || !mounted) return;
|
||||
final connected = c.isConnected;
|
||||
if (connected && !_wasConnected) {
|
||||
_wasConnected = true;
|
||||
if (c.supportsOffbandCaplog) {
|
||||
_ensureStatusPolling();
|
||||
_refreshStatus();
|
||||
}
|
||||
} else if (!connected && _wasConnected) {
|
||||
_wasConnected = false;
|
||||
_stopTimers();
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshStatus() async {
|
||||
final c = _connector;
|
||||
if (c == null || !c.isConnected) return;
|
||||
try {
|
||||
final status = await c.getDeviceCaplogStatus();
|
||||
if (!mounted) return;
|
||||
setState(() => _status = status);
|
||||
// Keep local timers/elapsed in sync with the device's actual state, so an
|
||||
// auto-resumed capture after a reboot is reflected as running.
|
||||
if (status.enabled) {
|
||||
_startedAt ??= DateTime.now();
|
||||
_startTick();
|
||||
} else {
|
||||
_startedAt = null;
|
||||
_timedWindowMinutes = null;
|
||||
_stopTick();
|
||||
}
|
||||
} catch (_) {
|
||||
// Transient (e.g. mid-reconnect); keep last-known state and retry on the
|
||||
// next poll / reconnect rather than latching.
|
||||
}
|
||||
}
|
||||
|
||||
/// STATUS poll runs whenever the screen is on a connected, caplog-capable
|
||||
/// device, so the buffer readout stays live even while idle (not just during
|
||||
/// capture). Stopped only on disconnect / dispose.
|
||||
void _ensureStatusPolling() {
|
||||
_statusPoll ??= Timer.periodic(
|
||||
const Duration(seconds: 3),
|
||||
(_) => _refreshStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 1s elapsed-time tick; runs only while capturing.
|
||||
void _startTick() {
|
||||
_tick ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTick() {
|
||||
_tick?.cancel();
|
||||
_tick = null;
|
||||
}
|
||||
|
||||
void _stopTimers() {
|
||||
_tick?.cancel();
|
||||
_autoStop?.cancel();
|
||||
_statusPoll?.cancel();
|
||||
_tick = _autoStop = _statusPoll = null;
|
||||
}
|
||||
|
||||
Future<bool> _setEnabled(bool enabled) async {
|
||||
final c = _connector;
|
||||
if (c == null) return false;
|
||||
final ok = await c.setDeviceCaplogEnabled(enabled);
|
||||
if (!ok) {
|
||||
throw Exception('device rejected ${enabled ? 'enable' : 'disable'}');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
Future<void> _startTimed() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await _connector?.eraseDeviceCaplog(); // clean session: start from 0
|
||||
await _setEnabled(true);
|
||||
if (!mounted) return;
|
||||
_startedAt = DateTime.now();
|
||||
_timedWindowMinutes = _durationMinutes > 0 ? _durationMinutes : null;
|
||||
_startTick();
|
||||
if (_durationMinutes > 0) {
|
||||
_autoStop?.cancel();
|
||||
_autoStop = Timer(Duration(minutes: _durationMinutes), _stop);
|
||||
}
|
||||
await _refreshStatus();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start capture: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stop() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await _setEnabled(false);
|
||||
if (mounted) {
|
||||
_startedAt = null;
|
||||
_timedWindowMinutes = null;
|
||||
}
|
||||
_autoStop?.cancel();
|
||||
_autoStop = null;
|
||||
_stopTick();
|
||||
await _refreshStatus();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not stop capture: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot-log flow (#428): enable capture with no timer, then reboot so the
|
||||
/// radio records the boot sequence from power-on. The connection drops during
|
||||
/// reboot; on reconnect `_onConnectorChanged` re-derives STATUS and (once
|
||||
/// firmware #428 persists the flag) the capture shows as running → Stop here.
|
||||
Future<void> _startAndReboot() async {
|
||||
final c = _connector;
|
||||
if (c == null) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Start capture & reboot?'),
|
||||
content: const Text(
|
||||
'Enables serial capture, then reboots the radio so the boot log is '
|
||||
'captured from power-on. There is no timer. Capture runs until you '
|
||||
'Stop it. The connection drops during the reboot; when it reconnects, '
|
||||
'capture is still running and you can Stop and download here.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red[700]),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Start & Reboot'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await c.eraseDeviceCaplog(); // fresh boot log from power-on
|
||||
await _setEnabled(true); // no timer: runs until Stop
|
||||
_startedAt = DateTime.now();
|
||||
_timedWindowMinutes = null;
|
||||
await c.rebootDevice();
|
||||
// Connection drops now; reconnect handling re-derives STATUS.
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Start & reboot failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download() async {
|
||||
final c = _connector;
|
||||
if (c == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final bytes = await c.downloadCaplog();
|
||||
if (!mounted) return;
|
||||
if (bytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Buffer is empty, nothing to download.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
final ts = DateTime.now();
|
||||
final name =
|
||||
'serial-capture-'
|
||||
'${ts.year}${_pad2(ts.month)}${_pad2(ts.day)}-'
|
||||
'${_pad2(ts.hour)}${_pad2(ts.minute)}${_pad2(ts.second)}.txt';
|
||||
final file = File('${dir.path}${Platform.pathSeparator}$name');
|
||||
await file.writeAsBytes(bytes);
|
||||
if (!mounted) return;
|
||||
await LogExport.shareFile(
|
||||
context,
|
||||
file,
|
||||
subject: 'Offband serial capture',
|
||||
);
|
||||
} on CaplogBusyException {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _error = 'Device busy (another transfer in progress). Retry.',
|
||||
);
|
||||
}
|
||||
} on CaplogTruncatedException catch (e) {
|
||||
if (mounted) setState(() => _error = 'Capture truncated: $e');
|
||||
} on TimeoutException {
|
||||
if (mounted) setState(() => _error = 'No response from device.');
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Download failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _erase() async {
|
||||
final c = _connector;
|
||||
if (c == null) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await c.eraseDeviceCaplog();
|
||||
await _refreshStatus();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Erase failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
static String _pad2(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
String _elapsed() {
|
||||
final s = _startedAt == null
|
||||
? 0
|
||||
: DateTime.now().difference(_startedAt!).inSeconds;
|
||||
return '${_pad2(s ~/ 60)}:${_pad2(s % 60)}';
|
||||
}
|
||||
|
||||
String _remaining() {
|
||||
final window = _timedWindowMinutes;
|
||||
if (_startedAt == null || window == null) return '';
|
||||
final total = window * 60;
|
||||
final left = (total - DateTime.now().difference(_startedAt!).inSeconds)
|
||||
.clamp(0, total);
|
||||
return '${_pad2(left ~/ 60)}:${_pad2(left % 60)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Serial capture'), centerTitle: true),
|
||||
body: _body(connector),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(MeshCoreConnector connector) {
|
||||
if (!connector.isConnected) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Connect to a device to use serial capture.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!connector.supportsOffbandCaplog) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"This device's firmware doesn't support serial capture.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final capturing = _status?.enabled ?? (_startedAt != null);
|
||||
final timed = _timedWindowMinutes != null;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (_error != null)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_error!)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() => _error = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
capturing ? 'Capturing…' : 'Idle',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (capturing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Elapsed ${_elapsed()}'
|
||||
'${timed ? ' · auto-stops in ${_remaining()}' : ''}',
|
||||
),
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: _status!.capacityBytes > 0
|
||||
? (_status!.usedBytes / _status!.capacityBytes).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Buffer ${_status!.usedBytes} / ${_status!.capacityBytes} bytes',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Capture duration'),
|
||||
const Spacer(),
|
||||
DropdownButton<int>(
|
||||
value: _durationMinutes,
|
||||
onChanged: capturing
|
||||
? null
|
||||
: (v) {
|
||||
if (v != null) setState(() => _durationMinutes = v);
|
||||
},
|
||||
items: _durations
|
||||
.map(
|
||||
(m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text(m == 0 ? 'Until I stop' : '$m min'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : (capturing ? _stop : _startTimed),
|
||||
icon: Icon(capturing ? Icons.stop : Icons.fiber_manual_record),
|
||||
label: Text(capturing ? 'Stop capture' : 'Start capture'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy || capturing ? null : _download,
|
||||
icon: Icon(LogExport.icon),
|
||||
label: const Text('Download & share'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _busy || capturing ? null : _erase,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Erase buffer'),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
// Boot-log flow (#428): enable capture (no timer) + reboot, styled as a
|
||||
// dangerous action (it reboots the radio and drops the connection).
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red[700],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: _busy || capturing ? null : _startAndReboot,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('Start & Reboot (capture boot log)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/config_catalog.dart';
|
||||
import '../../services/config_source_service.dart';
|
||||
import '../../services/observer_config_service.dart';
|
||||
import 'config_profile_preview_screen.dart';
|
||||
|
||||
/// Entry screen for importing a config profile (#407): browse the curated
|
||||
/// catalog or point at any source URL, then hand a fetched profile to the
|
||||
/// preview/apply screen (#406).
|
||||
class ConfigProfileImportScreen extends StatefulWidget {
|
||||
const ConfigProfileImportScreen({super.key, required this.service});
|
||||
|
||||
/// The connected observer's config service, threaded to the preview screen.
|
||||
final ObserverConfigService service;
|
||||
|
||||
@override
|
||||
State<ConfigProfileImportScreen> createState() =>
|
||||
_ConfigProfileImportScreenState();
|
||||
}
|
||||
|
||||
class _ConfigProfileImportScreenState extends State<ConfigProfileImportScreen> {
|
||||
final _source = ConfigSourceService();
|
||||
final _urlController = TextEditingController();
|
||||
|
||||
List<CatalogEntry> _entries = const [];
|
||||
int _skipped = 0;
|
||||
String? _catalogLabel; // which catalog is shown (default vs custom)
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
bool _busy = false; // fetching a single profile before navigating
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCatalog(kDefaultCatalogUrl, label: 'Offband catalog');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
_source.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadCatalog(String url, {required String label}) async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final catalog = await _source.fetchCatalog(url);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_entries = catalog.published;
|
||||
_skipped = catalog.skippedEntries;
|
||||
_catalogLabel = label;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = _msg(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the URL box: a catalog URL replaces the list; a single-profile URL
|
||||
/// goes straight to preview.
|
||||
Future<void> _openUrl() async {
|
||||
final raw = _urlController.text.trim();
|
||||
if (raw.isEmpty) return;
|
||||
setState(() => _error = null);
|
||||
final ResolvedSource resolved;
|
||||
try {
|
||||
resolved = resolveSourceUrl(raw);
|
||||
} catch (e) {
|
||||
setState(() => _error = _msg(e));
|
||||
return;
|
||||
}
|
||||
if (resolved.kind == SourceKind.catalog) {
|
||||
await _loadCatalog(resolved.url, label: 'Custom catalog');
|
||||
} else {
|
||||
await _openProfile(resolved.url);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openProfile(String url) async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final profile = await _source.fetchProfile(url);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ConfigProfilePreviewScreen(
|
||||
profile: profile,
|
||||
service: widget.service,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = _msg(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _msg(Object e) {
|
||||
if (e is ConfigSourceException) return e.message;
|
||||
if (e is ConfigCatalogFormatException) return e.message;
|
||||
return e.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: const Text('Import config profile'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Source URL (catalog or .yaml)',
|
||||
helperText: 'A region\'s catalog, or a direct profile',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _openUrl(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _openUrl,
|
||||
child: const Text('Load'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 8),
|
||||
Expanded(child: _catalogBody(theme)),
|
||||
if (_busy) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _catalogBody(ThemeData theme) {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_entries.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error == null
|
||||
? 'No profiles published in ${_catalogLabel ?? 'this catalog'} yet.'
|
||||
: 'Could not load the catalog.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(_catalogLabel ?? 'Catalog', style: theme.textTheme.titleSmall),
|
||||
if (_skipped > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'$_skipped malformed ${_skipped == 1 ? 'entry' : 'entries'} skipped',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final e in _entries)
|
||||
Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: Text(e.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (e.region != null) 'Region: ${e.region}',
|
||||
if (e.description != null) e.description!,
|
||||
].join('\n'),
|
||||
),
|
||||
isThreeLine: e.description != null && e.region != null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _busy ? null : () => _openProfile(e.url),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,325 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../helpers/config_profile_diff.dart';
|
||||
import '../../helpers/config_profile_writes.dart';
|
||||
import '../../models/config_profile.dart';
|
||||
import '../../services/observer_apply_service.dart';
|
||||
import '../../services/observer_config_service.dart';
|
||||
|
||||
/// Full sub-screen preview of a config-profile apply (#406): shows the current
|
||||
/// -> new diff, then a two-tier confirm, a normal Apply for plain config, and a
|
||||
/// separate red gate for credential/identity changes.
|
||||
class ConfigProfilePreviewScreen extends StatefulWidget {
|
||||
const ConfigProfilePreviewScreen({
|
||||
super.key,
|
||||
required this.profile,
|
||||
required this.service,
|
||||
});
|
||||
|
||||
final ConfigProfile profile;
|
||||
final ObserverConfigService service;
|
||||
|
||||
@override
|
||||
State<ConfigProfilePreviewScreen> createState() =>
|
||||
_ConfigProfilePreviewScreenState();
|
||||
}
|
||||
|
||||
class _ConfigProfilePreviewScreenState
|
||||
extends State<ConfigProfilePreviewScreen> {
|
||||
late ProfileWrites _writes;
|
||||
ProfileDiff? _diff;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
bool _applying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_writes = enumerateProfileWrites(widget.profile);
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final currentFlat = <String, String?>{};
|
||||
for (final f in _writes.flats) {
|
||||
currentFlat[f.key] = await widget.service.getFlat(f.key);
|
||||
}
|
||||
final currentBroker = <int, Map<String, String?>>{};
|
||||
for (final b in _writes.brokers) {
|
||||
final m = <String, String?>{};
|
||||
for (final key in [...b.fields.keys, ConfigKeys.brokerEnabled]) {
|
||||
m[key] = await widget.service.getFlat(ConfigKeys.broker(b.slot, key));
|
||||
}
|
||||
currentBroker[b.slot] = m;
|
||||
}
|
||||
final diff = buildProfileDiff(
|
||||
_writes,
|
||||
currentFlat: currentFlat,
|
||||
currentBroker: currentBroker,
|
||||
);
|
||||
if (mounted) setState(() => _diff = diff);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not read current config: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _apply(ProfileWrites writes, String what) async {
|
||||
setState(() => _applying = true);
|
||||
try {
|
||||
final result = await ObserverApplyService(widget.service).apply(writes);
|
||||
if (!mounted) return;
|
||||
final msg = result.allOk
|
||||
? '$what applied.'
|
||||
: '$what: ${result.failures.length} of ${result.items.length} failed '
|
||||
'(${result.failures.first.error ?? 'error'}).';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: result.allOk
|
||||
? null
|
||||
: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
await _load(); // re-diff against true state (partial-save recovery)
|
||||
} finally {
|
||||
if (mounted) setState(() => _applying = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final diff = _diff;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: const Text('Review config profile'),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorView(message: _error!, onRetry: _load)
|
||||
: diff == null
|
||||
? const SizedBox.shrink()
|
||||
: _buildBody(context, diff),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, ProfileDiff diff) {
|
||||
if (diff.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No changes: the device already matches this profile.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final split = splitProfileWrites(_writes);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.profile.name != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.profile.name!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final r in diff.safeRows) _DiffTile(row: r),
|
||||
if (diff.hasDanger) ...[
|
||||
const SizedBox(height: 16),
|
||||
_DangerHeader(count: diff.dangerRows.length),
|
||||
for (final r in diff.dangerRows)
|
||||
_DiffTile(row: r, danger: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _applying || diff.safeRows.isEmpty
|
||||
? null
|
||||
: () => _apply(split.safe, 'Config changes'),
|
||||
child: Text(
|
||||
diff.safeRows.isEmpty
|
||||
? 'No plain-config changes'
|
||||
: 'Apply ${diff.safeRows.length} config change'
|
||||
'${diff.safeRows.length == 1 ? '' : 's'}',
|
||||
),
|
||||
),
|
||||
if (diff.hasDanger) ...[
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
onPressed: _applying
|
||||
? null
|
||||
: () => _confirmDanger(context, split.danger, diff),
|
||||
child: Text(
|
||||
'Danger: change ${diff.dangerRows.length} '
|
||||
'credential/identity value'
|
||||
'${diff.dangerRows.length == 1 ? '' : 's'}',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDanger(
|
||||
BuildContext context,
|
||||
ProfileWrites dangerWrites,
|
||||
ProfileDiff diff,
|
||||
) async {
|
||||
final theme = Theme.of(context);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Change credential / identity values?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'These bind this device to an identity or credentials. Only proceed '
|
||||
'if you trust this profile:',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final r in diff.dangerRows)
|
||||
Text('• ${r.label}', style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Change them'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Guard the async gap: the screen may have been disposed while the dialog
|
||||
// was open; _apply's setState would then throw (Gemini review).
|
||||
if (ok == true && mounted) await _apply(dangerWrites, 'Credential changes');
|
||||
}
|
||||
}
|
||||
|
||||
class _DiffTile extends StatelessWidget {
|
||||
const _DiffTile({required this.row, this.danger = false});
|
||||
final DiffRow row;
|
||||
final bool danger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isChange = row.kind == DiffKind.change;
|
||||
final newText = row.secret ? '••••••' : row.newValue;
|
||||
final oldText = row.secret
|
||||
? '—'
|
||||
: (row.oldValue == null || row.oldValue!.isEmpty ? '—' : row.oldValue!);
|
||||
final accent = danger
|
||||
? theme.colorScheme.error
|
||||
: isChange
|
||||
? Colors.amber.shade800
|
||||
: theme.colorScheme.primary;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isChange ? Icons.edit_outlined : Icons.add_circle_outline,
|
||||
color: accent,
|
||||
),
|
||||
title: Text(row.label, style: theme.textTheme.bodyMedium),
|
||||
subtitle: Text(
|
||||
isChange ? '$oldText → $newText' : 'set to $newText',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DangerHeader extends StatelessWidget {
|
||||
const _DangerHeader({required this.count});
|
||||
final int count;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Credential / identity changes ($count), require the red button',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.message, required this.onRetry});
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: onRetry, child: const Text('Retry')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../helpers/config_profile_parser.dart';
|
||||
import '../models/config_catalog.dart';
|
||||
import '../models/config_profile.dart';
|
||||
|
||||
/// Fetches config profiles from a remote source (#404): the curated Offband
|
||||
/// catalog, or any region's self-hosted catalog / single profile.
|
||||
///
|
||||
/// Device-agnostic, observer (#139), repeater (#137), and companion (#138)
|
||||
/// share this. Only the apply step differs per device.
|
||||
|
||||
/// Default curated catalog (repo: OffbandMesh/config-profiles).
|
||||
const String kDefaultCatalogUrl =
|
||||
'https://raw.githubusercontent.com/OffbandMesh/config-profiles/main/profiles.json';
|
||||
|
||||
/// What a user-entered URL points at, decided by its tail.
|
||||
enum SourceKind { catalog, profile }
|
||||
|
||||
class ResolvedSource {
|
||||
const ResolvedSource(this.kind, this.url);
|
||||
final SourceKind kind;
|
||||
|
||||
/// The URL to actually fetch (may differ from the input, a directory URL
|
||||
/// resolves to `<dir>/profiles.json`).
|
||||
final String url;
|
||||
}
|
||||
|
||||
/// Thrown on any fetch/decode failure; message is user-facing.
|
||||
class ConfigSourceException implements Exception {
|
||||
const ConfigSourceException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => 'ConfigSourceException: $message';
|
||||
}
|
||||
|
||||
/// Resolve a user-entered URL by its tail (no reliance on HTTP directory
|
||||
/// listing):
|
||||
/// - ends `.json` -> a catalog manifest
|
||||
/// - ends `.yaml` / `.yml` -> a single profile
|
||||
/// - otherwise (trailing `/` or no filename) -> `<url>profiles.json`
|
||||
ResolvedSource resolveSourceUrl(String input) {
|
||||
final trimmed = input.trim();
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
|
||||
throw const ConfigSourceException(
|
||||
'Enter a full http(s) URL to a catalog or a .yaml profile',
|
||||
);
|
||||
}
|
||||
|
||||
final lower = trimmed.toLowerCase();
|
||||
if (lower.endsWith('.json')) {
|
||||
return ResolvedSource(SourceKind.catalog, trimmed);
|
||||
}
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
|
||||
return ResolvedSource(SourceKind.profile, trimmed);
|
||||
}
|
||||
// Directory / bare host: append the conventional manifest filename.
|
||||
final base = trimmed.endsWith('/') ? trimmed : '$trimmed/';
|
||||
return ResolvedSource(SourceKind.catalog, '${base}profiles.json');
|
||||
}
|
||||
|
||||
class ConfigSourceService {
|
||||
ConfigSourceService({http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Fetch + parse a catalog manifest.
|
||||
Future<ConfigCatalog> fetchCatalog(String url) async {
|
||||
return parseCatalog(await _get(url));
|
||||
}
|
||||
|
||||
/// Fetch + parse a single profile YAML.
|
||||
Future<ConfigProfile> fetchProfile(String url) async {
|
||||
return parseConfigProfile(await _get(url));
|
||||
}
|
||||
|
||||
Future<String> _get(String url) async {
|
||||
final http.Response resp;
|
||||
try {
|
||||
// Cache-bust for federated catalogs on normal servers/CDNs: a unique
|
||||
// query param + no-cache headers get them to serve fresh content.
|
||||
// KNOWN LIMITATION (#452): raw.githubusercontent, the DEFAULT catalog
|
||||
// host, ignores BOTH (verified: X-Cache HIT on a unique-query request)
|
||||
// and serves its cached copy for up to max-age=300 (~5 min). So the
|
||||
// default catalog can lag up to 5 min after an edit; this does not defeat
|
||||
// that. Kept because region-hosted catalogs elsewhere do honor it.
|
||||
final base = Uri.parse(url);
|
||||
final busted = base.replace(
|
||||
queryParameters: {
|
||||
...base.queryParameters,
|
||||
'_': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
resp = await _client.get(
|
||||
busted,
|
||||
headers: const {'Cache-Control': 'no-cache', 'Pragma': 'no-cache'},
|
||||
);
|
||||
} catch (e) {
|
||||
throw ConfigSourceException('Could not reach $url: $e');
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw ConfigSourceException('$url returned HTTP ${resp.statusCode}');
|
||||
}
|
||||
if (resp.body.isEmpty) {
|
||||
throw ConfigSourceException('$url returned an empty response');
|
||||
}
|
||||
return resp.body;
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import '../helpers/config_profile_writes.dart';
|
||||
import 'observer_config_service.dart';
|
||||
|
||||
/// Result of applying one write (or one broker slot). [label] is safe to show,
|
||||
/// it names the key/slot, never a value, so secrets never leak into UI or logs.
|
||||
class ApplyItemResult {
|
||||
const ApplyItemResult(this.label, this.ok, [this.error]);
|
||||
final String label;
|
||||
final bool ok;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
class ObserverApplyResult {
|
||||
const ObserverApplyResult(this.items);
|
||||
final List<ApplyItemResult> items;
|
||||
|
||||
bool get allOk => items.every((i) => i.ok);
|
||||
List<ApplyItemResult> get failures => items.where((i) => !i.ok).toList();
|
||||
}
|
||||
|
||||
/// Applies a [ProfileWrites] plan to the connected observer (#405).
|
||||
///
|
||||
/// Globals go through [ObserverConfigService.setFlat]; each broker through
|
||||
/// [ObserverConfigService.saveBroker], which already disables-first, writes
|
||||
/// field-at-a-time, writes `enabled` LAST, and stops at the first failure so a
|
||||
/// partial save never leaves a slot live-but-corrupt (#80). We read the slot's
|
||||
/// current `enabled` first so a profile that omits it preserves device state.
|
||||
class ObserverApplyService {
|
||||
ObserverApplyService(this._svc);
|
||||
final ObserverConfigService _svc;
|
||||
|
||||
Future<ObserverApplyResult> apply(ProfileWrites writes) async {
|
||||
final items = <ApplyItemResult>[];
|
||||
|
||||
for (final f in writes.flats) {
|
||||
final ok = await _svc.setFlat(f.key, f.value);
|
||||
items.add(ApplyItemResult(f.key, ok, ok ? null : _svc.lastError));
|
||||
}
|
||||
|
||||
for (final b in writes.brokers) {
|
||||
// Profiles never set broker enabled (#456), preserve the device's current
|
||||
// state: read wasLive for the safe-save dance and re-enable to the same.
|
||||
final current = await _svc.getBroker(b.slot);
|
||||
final wasLive = current?.enabled ?? false;
|
||||
|
||||
final res = await _svc.saveBroker(
|
||||
b.slot,
|
||||
fields: b.fields,
|
||||
enable: wasLive,
|
||||
wasLive: wasLive,
|
||||
);
|
||||
items.add(
|
||||
res.ok
|
||||
? ApplyItemResult('broker ${b.slot}', true)
|
||||
: ApplyItemResult(
|
||||
'broker ${b.slot}',
|
||||
false,
|
||||
'field "${res.failedField}" failed, slot left disabled',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ObserverApplyResult(items);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue