Skip to main content

edge_core/
intent.rs

1//! Input primitives and service intents exchanged by the routing engine.
2
3use serde::{Deserialize, Serialize};
4
5/// Physical input from a device. Device-agnostic: a Nuimo rotate and a
6/// dial rotate both produce `Rotate { delta }`.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub enum InputPrimitive {
9    Rotate {
10        delta: f64,
11    },
12    Press,
13    Release,
14    LongPress,
15    Swipe {
16        direction: Direction,
17    },
18    Slide {
19        value: f64,
20    },
21    Hover {
22        proximity: f64,
23    },
24    Touch {
25        area: TouchArea,
26    },
27    LongTouch {
28        area: TouchArea,
29    },
30    KeyPress {
31        key: u32,
32    },
33    /// Numbered button press from a multi-button controller (Hue Tap Dial
34    /// has 1..=4). Wire format: `"button_<id>"` (e.g. `button_1`).
35    Button {
36        id: u8,
37    },
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Direction {
43    Up,
44    Down,
45    Left,
46    Right,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TouchArea {
52    Top,
53    Bottom,
54    Left,
55    Right,
56}
57
58/// Service-level intent produced by the routing engine. Adapters translate
59/// this into their service's native command.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "type", rename_all = "snake_case")]
62pub enum Intent {
63    Play,
64    Pause,
65    PlayPause,
66    Stop,
67    Next,
68    Previous,
69    VolumeChange { delta: f64 },
70    VolumeSet { value: f64 },
71    Mute,
72    Unmute,
73    SeekRelative { seconds: f64 },
74    SeekAbsolute { seconds: f64 },
75    BrightnessChange { delta: f64 },
76    BrightnessSet { value: f64 },
77    ColorTemperatureChange { delta: f64 },
78    PowerToggle,
79    PowerOn,
80    PowerOff,
81}
82
83impl Intent {
84    /// Serialize the intent into its snake-case discriminant + remaining
85    /// params payload — matches the on-wire shape of
86    /// `EdgeToServer::Command` and `EdgeToServer::DispatchIntent`. Used
87    /// by both the dispatch telemetry frame and cross-edge intent
88    /// forwarding so both ends see the same encoding.
89    pub fn split(&self) -> (String, serde_json::Value) {
90        match serde_json::to_value(self) {
91            Ok(serde_json::Value::Object(mut map)) => {
92                let name = map
93                    .remove("type")
94                    .and_then(|v| v.as_str().map(str::to_string))
95                    .unwrap_or_else(|| "unknown".to_string());
96                (name, serde_json::Value::Object(map))
97            }
98            _ => ("unknown".to_string(), serde_json::json!({})),
99        }
100    }
101
102    /// Inverse of `split`: reassemble an `Intent` from its snake-case
103    /// discriminant and params payload. Used on the receiving end of
104    /// `ServerToEdge::DispatchIntent`. Returns `Err` if `intent` does
105    /// not name a known variant or if `params` shape doesn't match.
106    pub fn reassemble(intent: &str, params: &serde_json::Value) -> Result<Self, serde_json::Error> {
107        let value = match params {
108            serde_json::Value::Object(map) => {
109                let mut m = map.clone();
110                m.insert(
111                    "type".to_string(),
112                    serde_json::Value::String(intent.to_string()),
113                );
114                serde_json::Value::Object(m)
115            }
116            _ => serde_json::json!({ "type": intent }),
117        };
118        serde_json::from_value(value)
119    }
120}
121
122impl InputPrimitive {
123    /// Match this primitive against a wire-format route input string
124    /// (e.g. "rotate", "press", "swipe_right", "touch_top").
125    pub fn matches_route(&self, route_input: &str) -> bool {
126        match (self, route_input) {
127            (InputPrimitive::Rotate { .. }, "rotate") => true,
128            (InputPrimitive::Press, "press") => true,
129            (InputPrimitive::Release, "release") => true,
130            (InputPrimitive::LongPress, "long_press") => true,
131            (InputPrimitive::Slide { .. }, "slide") => true,
132            (InputPrimitive::Hover { .. }, "hover") => true,
133            (InputPrimitive::Swipe { direction }, s) => matches!(
134                (direction, s),
135                (Direction::Up, "swipe_up")
136                    | (Direction::Down, "swipe_down")
137                    | (Direction::Left, "swipe_left")
138                    | (Direction::Right, "swipe_right")
139            ),
140            (InputPrimitive::Touch { area }, s) => matches!(
141                (area, s),
142                (TouchArea::Top, "touch_top")
143                    | (TouchArea::Bottom, "touch_bottom")
144                    | (TouchArea::Left, "touch_left")
145                    | (TouchArea::Right, "touch_right")
146            ),
147            (InputPrimitive::LongTouch { area }, s) => matches!(
148                (area, s),
149                (TouchArea::Top, "long_touch_top")
150                    | (TouchArea::Bottom, "long_touch_bottom")
151                    | (TouchArea::Left, "long_touch_left")
152                    | (TouchArea::Right, "long_touch_right")
153            ),
154            (InputPrimitive::Button { id }, s) => s
155                .strip_prefix("button_")
156                .and_then(|n| n.parse::<u8>().ok())
157                .is_some_and(|parsed| parsed == *id),
158            _ => false,
159        }
160    }
161
162    /// Extract the continuous value for a rotate/slide, if applicable.
163    pub fn continuous_value(&self) -> Option<f64> {
164        match self {
165            InputPrimitive::Rotate { delta } | InputPrimitive::Slide { value: delta } => {
166                Some(*delta)
167            }
168            _ => None,
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn rotate_matches_rotate_route() {
179        let r = InputPrimitive::Rotate { delta: 0.03 };
180        assert!(r.matches_route("rotate"));
181        assert!(!r.matches_route("press"));
182    }
183
184    #[test]
185    fn swipe_direction_matters() {
186        let s = InputPrimitive::Swipe {
187            direction: Direction::Right,
188        };
189        assert!(s.matches_route("swipe_right"));
190        assert!(!s.matches_route("swipe_left"));
191        assert!(!s.matches_route("swipe_up"));
192    }
193
194    #[test]
195    fn button_id_matches_numbered_route() {
196        let b = InputPrimitive::Button { id: 1 };
197        assert!(b.matches_route("button_1"));
198        assert!(!b.matches_route("button_2"));
199        assert!(!b.matches_route("button_"));
200        assert!(!b.matches_route("press"));
201    }
202
203    #[test]
204    fn button_route_rejects_non_numeric_suffix() {
205        let b = InputPrimitive::Button { id: 3 };
206        assert!(!b.matches_route("button_x"));
207        assert!(!b.matches_route("button_3a"));
208        assert!(b.matches_route("button_3"));
209    }
210
211    #[test]
212    fn split_payloadless_intent_yields_empty_params() {
213        let (name, params) = Intent::PlayPause.split();
214        assert_eq!(name, "play_pause");
215        assert_eq!(params, serde_json::json!({}));
216    }
217
218    #[test]
219    fn split_continuous_intent_carries_params() {
220        let (name, params) = Intent::VolumeChange { delta: 0.25 }.split();
221        assert_eq!(name, "volume_change");
222        assert_eq!(params, serde_json::json!({ "delta": 0.25 }));
223    }
224
225    #[test]
226    fn reassemble_payloadless_round_trips() {
227        let (name, params) = Intent::Play.split();
228        let recovered = Intent::reassemble(&name, &params).unwrap();
229        assert_eq!(recovered, Intent::Play);
230    }
231
232    #[test]
233    fn reassemble_continuous_round_trips() {
234        let (name, params) = Intent::SeekRelative { seconds: -5.0 }.split();
235        let recovered = Intent::reassemble(&name, &params).unwrap();
236        assert_eq!(recovered, Intent::SeekRelative { seconds: -5.0 });
237    }
238
239    #[test]
240    fn reassemble_unknown_name_errors() {
241        let err = Intent::reassemble("definitely_not_an_intent", &serde_json::json!({}));
242        assert!(err.is_err());
243    }
244}