From 7e46e8522f7c409a4ae08fd474e8977af5cf4d4f Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 04:17:16 -0400 Subject: [PATCH] feat(#402): config-profile schema + model Typed model for importable device config profiles (feature #136): WifiConfig, BrokerConfig (6 slots), region/status-interval, and a ConfigProfile container. All fields nullable so apply engines write only the keys a profile sets. Key names + wire encoding mirror the firmware ConfigSchema (transport/auth as string names, wifi.pwd write-only). ConfigKeys centralizes the key strings so the parser (#403) and per-device apply engines never hard-code them. Co-Authored-By: Claude Opus 4.8 --- lib/models/config_profile.dart | 172 +++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 lib/models/config_profile.dart diff --git a/lib/models/config_profile.dart b/lib/models/config_profile.dart new file mode 100644 index 0000000..7f9287c --- /dev/null +++ b/lib/models/config_profile.dart @@ -0,0 +1,172 @@ +/// 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. +const int kConfigProfileSchemaVersion = 1; + +/// 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..*`). 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.enabled, + 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`. + final int slot; + final bool? enabled; + 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; +} + +/// A complete importable config profile. +class ConfigProfile { + const ConfigProfile({ + required this.schemaVersion, + this.wifi, + this.regionIata, + this.statusInterval, + this.brokers = const [], + this.name, + }); + + final int schemaVersion; + final WifiConfig? wifi; + + /// `mqtt.iata` — the region/IATA code applied globally. + final String? regionIata; + + /// `mqtt.status_interval` — seconds between status publishes. + final int? statusInterval; + + /// Populated broker slots only (may be sparse; each carries its [BrokerConfig.slot]). + final List brokers; + + /// Optional human label for the profile (not applied to the device). + final String? name; +} + +/// 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..`. +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..`. + 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'; +}