Skip to main content

dmx_interface/
config.rs

1//! Configuration data model for the DMX interface.
2//!
3//! Uses `heapless` fixed-size types for `no_std` compatibility. The JSON
4//! shape (field names, numeric enums, string-keyed index maps) mirrors
5//! `web/src/types/config.d.ts` — the WebSocket API is the contract between
6//! firmware and web UI; NVS keeps storing the same model via `postcard`.
7
8use core::fmt;
9
10use heapless::{String, Vec, index_map::FnvIndexMap};
11use serde::{Deserialize, Serialize};
12
13/// Maximum length for string configuration fields.
14const MAX_STR_LEN: usize = 32;
15
16/// Maximum number of DMX ports addressed by the config maps.
17const MAX_DMX_PORTS: usize = 4;
18
19/// Maximum number of button actions addressed by the config maps.
20const MAX_BUTTON_ACTIONS: usize = 4;
21
22/// Maximum size of the postcard-encoded config blob in NVS.
23const MAX_CONFIG_BYTES: usize = 512;
24
25/// Error for invalid enum discriminants arriving over the wire.
26#[derive(Debug)]
27pub struct InvalidValue(pub u8);
28
29impl fmt::Display for InvalidValue {
30    /// Render as `invalid enum value N`.
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "invalid enum value {}", self.0)
33    }
34}
35
36/// Network connection type (web: `ConnectionType`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(try_from = "u8", into = "u8")]
39pub enum ConnectionType {
40    /// Access Point mode (creates its own network).
41    WifiAp = 0,
42    /// Station mode (connects to an existing network).
43    WifiSta = 1,
44    /// Wired ethernet (not implemented yet).
45    Ethernet = 2,
46}
47
48impl Default for ConnectionType {
49    /// Devices boot as access point until configured otherwise.
50    fn default() -> Self {
51        Self::WifiAp
52    }
53}
54
55impl From<ConnectionType> for u8 {
56    /// `WifiAp` → 0, `WifiSta` → 1, `Ethernet` → 2.
57    fn from(value: ConnectionType) -> Self {
58        match value {
59            ConnectionType::WifiAp => 0,
60            ConnectionType::WifiSta => 1,
61            ConnectionType::Ethernet => 2,
62        }
63    }
64}
65
66impl TryFrom<u8> for ConnectionType {
67    /// Discriminant conversion failed.
68    type Error = InvalidValue;
69
70    /// Map a raw discriminant back to the enum.
71    fn try_from(value: u8) -> Result<Self, Self::Error> {
72        match value {
73            0 => Ok(Self::WifiAp),
74            1 => Ok(Self::WifiSta),
75            2 => Ok(Self::Ethernet),
76            other => Err(InvalidValue(other)),
77        }
78    }
79}
80
81/// IP assignment method (web: `IpMethod`); applied to station mode.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
83#[serde(try_from = "u8", into = "u8")]
84pub enum IpMethod {
85    /// Ask the network's DHCP server.
86    #[default]
87    Dhcp = 0,
88    /// Use a static address (not implemented yet).
89    Static = 1,
90}
91
92impl From<IpMethod> for u8 {
93    /// `Dhcp` → 0, `Static` → 1.
94    fn from(value: IpMethod) -> Self {
95        match value {
96            IpMethod::Dhcp => 0,
97            IpMethod::Static => 1,
98        }
99    }
100}
101
102impl TryFrom<u8> for IpMethod {
103    /// Discriminant conversion failed.
104    type Error = InvalidValue;
105
106    /// Map a raw discriminant back to the enum.
107    fn try_from(value: u8) -> Result<Self, Self::Error> {
108        match value {
109            0 => Ok(Self::Dhcp),
110            1 => Ok(Self::Static),
111            other => Err(InvalidValue(other)),
112        }
113    }
114}
115
116/// Action bound to a button event (web: `ButtonAction`).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[serde(try_from = "u8", into = "u8")]
119pub enum ButtonAction {
120    /// No action.
121    #[default]
122    None = 0,
123    /// Toggle the LED effect.
124    ToggleLed = 1,
125    /// Reboot the device.
126    Reboot = 2,
127}
128
129impl From<ButtonAction> for u8 {
130    /// `None` → 0, `ToggleLed` → 1, `Reboot` → 2.
131    fn from(value: ButtonAction) -> Self {
132        match value {
133            ButtonAction::None => 0,
134            ButtonAction::ToggleLed => 1,
135            ButtonAction::Reboot => 2,
136        }
137    }
138}
139
140impl TryFrom<u8> for ButtonAction {
141    /// Discriminant conversion failed.
142    type Error = InvalidValue;
143
144    /// Map a raw discriminant back to the enum.
145    fn try_from(value: u8) -> Result<Self, Self::Error> {
146        match value {
147            0 => Ok(Self::None),
148            1 => Ok(Self::ToggleLed),
149            2 => Ok(Self::Reboot),
150            other => Err(InvalidValue(other)),
151        }
152    }
153}
154
155/// DMX data direction (web: `direction` on `DmxPort`).
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(try_from = "u8", into = "u8")]
158pub enum DmxDirection {
159    /// Receive DMX data from a controller.
160    Input = 0,
161    /// Send DMX data to fixtures.
162    Output = 1,
163}
164
165impl Default for DmxDirection {
166    /// Output mode is the default.
167    fn default() -> Self {
168        Self::Output
169    }
170}
171
172impl From<DmxDirection> for u8 {
173    /// `Input` → 0, `Output` → 1.
174    fn from(value: DmxDirection) -> Self {
175        match value {
176            DmxDirection::Input => 0,
177            DmxDirection::Output => 1,
178        }
179    }
180}
181
182impl TryFrom<u8> for DmxDirection {
183    /// Discriminant conversion failed.
184    type Error = InvalidValue;
185
186    /// Map a raw discriminant back to the enum.
187    fn try_from(value: u8) -> Result<Self, Self::Error> {
188        match value {
189            0 => Ok(Self::Input),
190            1 => Ok(Self::Output),
191            other => Err(InvalidValue(other)),
192        }
193    }
194}
195
196/// DMX port configuration (web: `DmxPort`).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198pub struct DmxPortConfig {
199    /// Data direction (input or output).
200    pub direction: DmxDirection,
201    /// DMX universe number (0–32767).
202    pub universe: u16,
203}
204
205impl Default for DmxPortConfig {
206    /// Output mode on universe 1.
207    fn default() -> Self {
208        Self {
209            direction: DmxDirection::Output,
210            universe: 1,
211        }
212    }
213}
214
215/// `WiFi` credentials for one interface (web: `WifiConfig`).
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct WifiConfig {
218    /// Network name (empty AP SSID derives from the MAC address).
219    pub ssid: String<MAX_STR_LEN>,
220    /// `WiFi` password.
221    pub password: String<MAX_STR_LEN>,
222}
223
224impl Default for WifiConfig {
225    /// Empty credentials.
226    fn default() -> Self {
227        Self {
228            ssid: String::new(),
229            password: String::new(),
230        }
231    }
232}
233
234/// `serde` adapter: index maps as JSON objects with decimal string keys.
235///
236/// The web UI addresses ports by object key (`dmx_ports["0"]`), while
237/// `serde-json-core` would emit integer keys as bare digits — invalid JSON
238/// that browsers reject. Serializing through `String<N>` and parsing the
239/// keys back keeps both directions valid.
240mod index_map {
241    use core::fmt::{self, Write as _};
242    use core::marker::PhantomData;
243
244    use heapless::{String, index_map::FnvIndexMap};
245    use serde::de::{self, MapAccess, Visitor};
246    use serde::ser::{SerializeMap, Serializer};
247    use serde::{Deserialize, Deserializer, Serialize};
248
249    /// Serialize `FnvIndexMap<u8, T, N>` as `{"0": …, "1": …}`.
250    pub fn serialize<S: Serializer, T: Serialize, const N: usize>(
251        map: &FnvIndexMap<u8, T, N>,
252        serializer: S,
253    ) -> Result<S::Ok, S::Error> {
254        /// Wrapper struct so `serde` serializes the map key as a string.
255        struct Key(u8);
256
257        impl Serialize for Key {
258            /// Write the decimal digits as a JSON string.
259            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
260                let mut text = String::<4>::new();
261                write!(&mut text, "{}", self.0).map_err(serde::ser::Error::custom)?;
262                serializer.serialize_str(text.as_str())
263            }
264        }
265
266        let mut out = serializer.serialize_map(Some(map.len()))?;
267        for (key, value) in map {
268            out.serialize_entry(&Key(*key), value)?;
269        }
270        out.end()
271    }
272
273    /// Deserialize `{"0": …, "1": …}` back into `FnvIndexMap<u8, T, N>`.
274    pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de>, const N: usize>(
275        deserializer: D,
276    ) -> Result<FnvIndexMap<u8, T, N>, D::Error> {
277        /// Visitor collecting string-keyed entries into the map.
278        struct MapVisitor<T, const N: usize>(PhantomData<T>);
279
280        impl<'de, T: Deserialize<'de>, const N: usize> Visitor<'de> for MapVisitor<T, N> {
281            /// The map produced by this visitor.
282            type Value = FnvIndexMap<u8, T, N>;
283
284            /// Human-readable description for parse errors.
285            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286                f.write_str("a map with decimal string keys")
287            }
288
289            /// Parse each `"key": value` entry into the map.
290            fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
291                let mut map = FnvIndexMap::new();
292                while let Some((key, value)) = access.next_entry::<String<8>, T>()? {
293                    let index: u8 = key.parse().map_err(de::Error::custom)?;
294                    map.insert(index, value)
295                        .map_err(|_| de::Error::custom("config map full"))?;
296                }
297                Ok(map)
298            }
299        }
300
301        deserializer.deserialize_map(MapVisitor(PhantomData))
302    }
303}
304
305/// Complete device configuration (web: `Config`).
306///
307/// Field suffixes mirror the web JSON contract — renaming for the lint
308/// would break the API.
309#[allow(clippy::struct_field_names)]
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct Config {
312    /// Configuration schema version (for future migration).
313    pub version: u8,
314    /// Network connection type.
315    pub connection: ConnectionType,
316    /// IP assignment method for station mode.
317    pub ip_method: IpMethod,
318    /// LED brightness (0–255).
319    pub led_brightness: u8,
320    /// `WiFi` station credentials.
321    pub station_config: WifiConfig,
322    /// `WiFi` access-point credentials (empty SSID derives from the MAC).
323    pub ap_config: WifiConfig,
324    /// DMX ports by port index.
325    #[serde(with = "index_map")]
326    pub dmx_ports: FnvIndexMap<u8, DmxPortConfig, MAX_DMX_PORTS>,
327    /// Button actions by button index.
328    #[serde(with = "index_map")]
329    pub button_actions: FnvIndexMap<u8, ButtonAction, MAX_BUTTON_ACTIONS>,
330}
331
332impl Default for Config {
333    /// Factory defaults: AP mode, DMX0 output on universe 1, DMX1 input on
334    /// universe 2, dim LED, single click toggles the LED, triple click
335    /// reboots.
336    fn default() -> Self {
337        let ap_config = WifiConfig {
338            password: String::try_from("ChaosDMX").expect("default password fits"),
339            ..WifiConfig::default()
340        };
341        let mut dmx_ports = FnvIndexMap::new();
342        let _ = dmx_ports.insert(0, DmxPortConfig::default());
343        let _ = dmx_ports.insert(
344            1,
345            DmxPortConfig {
346                direction: DmxDirection::Input,
347                universe: 2,
348            },
349        );
350        // Keys are the `ButtonEvent` discriminants handled by
351        // `hardware::button` (0 single, 1 double, 2 multi, 3 long hold);
352        // defaults mirror the ESP-IDF firmware (single → ToggleLed,
353        // multi → Reboot, the rest unbound).
354        let mut button_actions = FnvIndexMap::new();
355        for (key, action) in [
356            (0, ButtonAction::ToggleLed),
357            (1, ButtonAction::None),
358            (2, ButtonAction::Reboot),
359            (3, ButtonAction::None),
360        ] {
361            let _ = button_actions.insert(key, action);
362        }
363        Self {
364            version: 1,
365            connection: ConnectionType::default(),
366            ip_method: IpMethod::default(),
367            led_brightness: 25,
368            station_config: WifiConfig::default(),
369            ap_config,
370            dmx_ports,
371            button_actions,
372        }
373    }
374}
375
376impl Config {
377    /// Serialize config to bytes using postcard (binary format).
378    ///
379    /// Returns a `Vec` with max [`MAX_CONFIG_BYTES`] capacity.
380    pub fn to_bytes(&self) -> Result<Vec<u8, MAX_CONFIG_BYTES>, postcard::Error> {
381        let mut buf = [0u8; MAX_CONFIG_BYTES];
382        let used = postcard::to_slice(self, &mut buf)?;
383        Vec::from_slice(used).map_err(|_| postcard::Error::SerializeBufferFull)
384    }
385
386    /// Deserialize config from bytes.
387    pub fn from_bytes(data: &[u8]) -> Result<Self, postcard::Error> {
388        postcard::from_bytes(data)
389    }
390
391    /// Pushes this configuration into every driver that consumes it.
392    ///
393    /// Called once at boot after loading and again whenever the config
394    /// changes (WebSocket `config.set`, factory reset) — the single place
395    /// mapping config fields onto driver state. Network changes apply on
396    /// the next reboot.
397    pub fn apply(&self) {
398        crate::hardware::led::set_brightness(self.led_brightness);
399    }
400
401    /// Merge a partial patch onto this config (present fields win).
402    ///
403    /// This is the server-side counterpart of the web UI's
404    /// `DeepPartial<Config>` PATCH semantics: nested objects
405    /// (`ap_config`, `station_config`, `dmx_ports[*]`) merge field by
406    /// field, map entries merge per key, scalar fields replace when
407    /// present.
408    ///
409    /// Returns `false` when the patch is rejected — a map key outside
410    /// `0..MAX_*` or a map that is already full. Keys are validated before
411    /// anything is merged, so a rejected patch leaves the config untouched
412    /// (HTTP/WS answer 400 and must not persist).
413    pub fn merge_patch(&mut self, patch: ConfigPatch) -> bool {
414        if patch
415            .dmx_ports
416            .keys()
417            .any(|&key| usize::from(key) >= MAX_DMX_PORTS)
418            || patch
419                .button_actions
420                .keys()
421                .any(|&key| usize::from(key) >= MAX_BUTTON_ACTIONS)
422        {
423            return false;
424        }
425        if let Some(version) = patch.version {
426            self.version = version;
427        }
428        if let Some(connection) = patch.connection {
429            self.connection = connection;
430        }
431        if let Some(ip_method) = patch.ip_method {
432            self.ip_method = ip_method;
433        }
434        if let Some(led_brightness) = patch.led_brightness {
435            self.led_brightness = led_brightness;
436        }
437        if let Some(station_config) = patch.station_config {
438            station_config.apply(&mut self.station_config);
439        }
440        if let Some(ap_config) = patch.ap_config {
441            ap_config.apply(&mut self.ap_config);
442        }
443        for (key, port_patch) in patch.dmx_ports {
444            let mut port = self.dmx_ports.get(&key).copied().unwrap_or_default();
445            port_patch.apply(&mut port);
446            if self.dmx_ports.insert(key, port).is_err() {
447                return false;
448            }
449        }
450        for (key, action) in patch.button_actions {
451            if self.button_actions.insert(key, action).is_err() {
452                return false;
453            }
454        }
455        true
456    }
457}
458
459/// One [`WifiConfig`] entry as it arrives in a patch (web: `DeepPartial`).
460///
461/// The UI edits credentials leaf by leaf, so both fields are optional
462/// and absent fields keep their current value.
463#[derive(Debug, Default, Deserialize)]
464pub struct WifiConfigPatch {
465    /// Optional network name.
466    pub ssid: Option<String<MAX_STR_LEN>>,
467    /// Optional password.
468    pub password: Option<String<MAX_STR_LEN>>,
469}
470
471impl WifiConfigPatch {
472    /// Move the present fields onto `target`; absent fields stay untouched.
473    fn apply(self, target: &mut WifiConfig) {
474        if let Some(ssid) = self.ssid {
475            target.ssid = ssid;
476        }
477        if let Some(password) = self.password {
478            target.password = password;
479        }
480    }
481}
482
483/// One [`DmxPortConfig`] entry as it arrives in a patch (web: `DeepPartial`).
484#[derive(Debug, Default, Deserialize)]
485pub struct DmxPortPatch {
486    /// Optional data direction.
487    pub direction: Option<DmxDirection>,
488    /// Optional DMX universe number.
489    pub universe: Option<u16>,
490}
491
492impl DmxPortPatch {
493    /// Move the present fields onto `target`; absent fields stay untouched.
494    fn apply(self, target: &mut DmxPortConfig) {
495        if let Some(direction) = self.direction {
496            target.direction = direction;
497        }
498        if let Some(universe) = self.universe {
499            target.universe = universe;
500        }
501    }
502}
503
504/// Partial configuration (web: `DeepPartial<Config>`).
505///
506/// Every field is optional — including nested objects: credentials and
507/// DMX-port entries arrive leaf by leaf (the UI only sends changed
508/// leaves) and merge field-wise onto the stored values.
509#[derive(Debug, Default, Deserialize)]
510pub struct ConfigPatch {
511    /// Optional schema version.
512    pub version: Option<u8>,
513    /// Optional connection type.
514    pub connection: Option<ConnectionType>,
515    /// Optional IP method.
516    pub ip_method: Option<IpMethod>,
517    /// Optional LED brightness.
518    pub led_brightness: Option<u8>,
519    /// Optional station credentials (fields merge individually).
520    pub station_config: Option<WifiConfigPatch>,
521    /// Optional access-point credentials (fields merge individually).
522    pub ap_config: Option<WifiConfigPatch>,
523    /// Optional per-port overrides (merged per key and field).
524    #[serde(default, with = "index_map")]
525    pub dmx_ports: FnvIndexMap<u8, DmxPortPatch, MAX_DMX_PORTS>,
526    /// Optional per-button overrides (merged per key).
527    #[serde(default, with = "index_map")]
528    pub button_actions: FnvIndexMap<u8, ButtonAction, MAX_BUTTON_ACTIONS>,
529}
530
531/// Serialization round-trip tests.
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    /// Encode → decode must preserve the important fields.
537    #[test]
538    fn config_roundtrip() {
539        let config = Config::default();
540        let bytes = config.to_bytes().unwrap();
541        let restored = Config::from_bytes(&bytes).unwrap();
542        assert_eq!(config.connection, restored.connection);
543        assert_eq!(config.led_brightness, restored.led_brightness);
544        assert_eq!(
545            config.dmx_ports.get(&0).map(|port| port.universe),
546            restored.dmx_ports.get(&0).map(|port| port.universe)
547        );
548    }
549}