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    /// In-air swipe (Nuimo only): the user waves a hand left/right above
39    /// the device without touching the surface. Distinct from `Swipe`
40    /// (which involves physical contact) so route mappings can target
41    /// either independently. Nuimo only emits Left/Right; the enum reuses
42    /// `Direction` for symmetry with `Swipe` rather than introducing a
43    /// new two-variant type.
44    Fly {
45        direction: Direction,
46    },
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Direction {
52    Up,
53    Down,
54    Left,
55    Right,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum TouchArea {
61    Top,
62    Bottom,
63    Left,
64    Right,
65}
66
67/// Service-level intent produced by the routing engine. Adapters translate
68/// this into their service's native command.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum Intent {
72    Play,
73    Pause,
74    PlayPause,
75    Stop,
76    Next,
77    Previous,
78    VolumeChange { delta: f64 },
79    VolumeSet { value: f64 },
80    Mute,
81    Unmute,
82    SeekRelative { seconds: f64 },
83    SeekAbsolute { seconds: f64 },
84    BrightnessChange { delta: f64 },
85    BrightnessSet { value: f64 },
86    ColorTemperatureChange { delta: f64 },
87    PowerToggle,
88    PowerOn,
89    PowerOff,
90}
91
92impl Intent {
93    /// Serialize the intent into its snake-case discriminant + remaining
94    /// params payload — matches the on-wire shape of
95    /// `EdgeToServer::Command` and `EdgeToServer::DispatchIntent`. Used
96    /// by both the dispatch telemetry frame and cross-edge intent
97    /// forwarding so both ends see the same encoding.
98    pub fn split(&self) -> (String, serde_json::Value) {
99        match serde_json::to_value(self) {
100            Ok(serde_json::Value::Object(mut map)) => {
101                let name = map
102                    .remove("type")
103                    .and_then(|v| v.as_str().map(str::to_string))
104                    .unwrap_or_else(|| "unknown".to_string());
105                (name, serde_json::Value::Object(map))
106            }
107            _ => ("unknown".to_string(), serde_json::json!({})),
108        }
109    }
110
111    /// Inverse of `split`: reassemble an `Intent` from its snake-case
112    /// discriminant and params payload. Used on the receiving end of
113    /// `ServerToEdge::DispatchIntent`. Returns `Err` if `intent` does
114    /// not name a known variant or if `params` shape doesn't match.
115    pub fn reassemble(intent: &str, params: &serde_json::Value) -> Result<Self, serde_json::Error> {
116        let value = match params {
117            serde_json::Value::Object(map) => {
118                let mut m = map.clone();
119                m.insert(
120                    "type".to_string(),
121                    serde_json::Value::String(intent.to_string()),
122                );
123                serde_json::Value::Object(m)
124            }
125            _ => serde_json::json!({ "type": intent }),
126        };
127        serde_json::from_value(value)
128    }
129}
130
131impl InputPrimitive {
132    /// Match this primitive against a wire-format route input string
133    /// (e.g. "rotate", "press", "swipe_right", "touch_top").
134    pub fn matches_route(&self, route_input: &str) -> bool {
135        match (self, route_input) {
136            (InputPrimitive::Rotate { .. }, "rotate") => true,
137            (InputPrimitive::Press, "press") => true,
138            (InputPrimitive::Release, "release") => true,
139            (InputPrimitive::LongPress, "long_press") => true,
140            (InputPrimitive::Slide { .. }, "slide") => true,
141            (InputPrimitive::Hover { .. }, "hover") => true,
142            (InputPrimitive::Swipe { direction }, s) => matches!(
143                (direction, s),
144                (Direction::Up, "swipe_up")
145                    | (Direction::Down, "swipe_down")
146                    | (Direction::Left, "swipe_left")
147                    | (Direction::Right, "swipe_right")
148            ),
149            (InputPrimitive::Touch { area }, s) => matches!(
150                (area, s),
151                (TouchArea::Top, "touch_top")
152                    | (TouchArea::Bottom, "touch_bottom")
153                    | (TouchArea::Left, "touch_left")
154                    | (TouchArea::Right, "touch_right")
155            ),
156            (InputPrimitive::LongTouch { area }, s) => matches!(
157                (area, s),
158                (TouchArea::Top, "long_touch_top")
159                    | (TouchArea::Bottom, "long_touch_bottom")
160                    | (TouchArea::Left, "long_touch_left")
161                    | (TouchArea::Right, "long_touch_right")
162            ),
163            (InputPrimitive::Button { id }, s) => s
164                .strip_prefix("button_")
165                .and_then(|n| n.parse::<u8>().ok())
166                .is_some_and(|parsed| parsed == *id),
167            (InputPrimitive::Fly { direction }, s) => matches!(
168                (direction, s),
169                (Direction::Left, "fly_left") | (Direction::Right, "fly_right")
170            ),
171            _ => false,
172        }
173    }
174
175    /// Extract the continuous value for a rotate/slide, if applicable.
176    pub fn continuous_value(&self) -> Option<f64> {
177        match self {
178            InputPrimitive::Rotate { delta } | InputPrimitive::Slide { value: delta } => {
179                Some(*delta)
180            }
181            _ => None,
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn rotate_matches_rotate_route() {
192        let r = InputPrimitive::Rotate { delta: 0.03 };
193        assert!(r.matches_route("rotate"));
194        assert!(!r.matches_route("press"));
195    }
196
197    #[test]
198    fn swipe_direction_matters() {
199        let s = InputPrimitive::Swipe {
200            direction: Direction::Right,
201        };
202        assert!(s.matches_route("swipe_right"));
203        assert!(!s.matches_route("swipe_left"));
204        assert!(!s.matches_route("swipe_up"));
205    }
206
207    #[test]
208    fn button_id_matches_numbered_route() {
209        let b = InputPrimitive::Button { id: 1 };
210        assert!(b.matches_route("button_1"));
211        assert!(!b.matches_route("button_2"));
212        assert!(!b.matches_route("button_"));
213        assert!(!b.matches_route("press"));
214    }
215
216    #[test]
217    fn button_route_rejects_non_numeric_suffix() {
218        let b = InputPrimitive::Button { id: 3 };
219        assert!(!b.matches_route("button_x"));
220        assert!(!b.matches_route("button_3a"));
221        assert!(b.matches_route("button_3"));
222    }
223
224    #[test]
225    fn fly_left_matches_only_fly_left_route() {
226        let f = InputPrimitive::Fly {
227            direction: Direction::Left,
228        };
229        assert!(f.matches_route("fly_left"));
230        assert!(!f.matches_route("fly_right"));
231        assert!(!f.matches_route("swipe_left"));
232    }
233
234    #[test]
235    fn fly_right_matches_only_fly_right_route() {
236        let f = InputPrimitive::Fly {
237            direction: Direction::Right,
238        };
239        assert!(f.matches_route("fly_right"));
240        assert!(!f.matches_route("fly_left"));
241        assert!(!f.matches_route("swipe_right"));
242    }
243
244    #[test]
245    fn fly_up_down_directions_do_not_match_any_fly_route() {
246        // Nuimo doesn't emit fly_up / fly_down at the device level —
247        // those codes carry proximity (decoded as `Hover`). If a caller
248        // synthesises `Fly { Up }` / `Fly { Down }`, no route string
249        // matches: the wire vocabulary has no `fly_up` / `fly_down`.
250        for dir in [Direction::Up, Direction::Down] {
251            let f = InputPrimitive::Fly { direction: dir };
252            assert!(!f.matches_route("fly_up"));
253            assert!(!f.matches_route("fly_down"));
254            assert!(!f.matches_route("fly_left"));
255            assert!(!f.matches_route("fly_right"));
256        }
257    }
258
259    #[test]
260    fn fly_does_not_collide_with_swipe_route() {
261        let fly = InputPrimitive::Fly {
262            direction: Direction::Left,
263        };
264        let swipe = InputPrimitive::Swipe {
265            direction: Direction::Left,
266        };
267        assert!(fly.matches_route("fly_left"));
268        assert!(!fly.matches_route("swipe_left"));
269        assert!(swipe.matches_route("swipe_left"));
270        assert!(!swipe.matches_route("fly_left"));
271    }
272
273    #[test]
274    fn split_payloadless_intent_yields_empty_params() {
275        let (name, params) = Intent::PlayPause.split();
276        assert_eq!(name, "play_pause");
277        assert_eq!(params, serde_json::json!({}));
278    }
279
280    #[test]
281    fn split_continuous_intent_carries_params() {
282        let (name, params) = Intent::VolumeChange { delta: 0.25 }.split();
283        assert_eq!(name, "volume_change");
284        assert_eq!(params, serde_json::json!({ "delta": 0.25 }));
285    }
286
287    #[test]
288    fn reassemble_payloadless_round_trips() {
289        let (name, params) = Intent::Play.split();
290        let recovered = Intent::reassemble(&name, &params).unwrap();
291        assert_eq!(recovered, Intent::Play);
292    }
293
294    #[test]
295    fn reassemble_continuous_round_trips() {
296        let (name, params) = Intent::SeekRelative { seconds: -5.0 }.split();
297        let recovered = Intent::reassemble(&name, &params).unwrap();
298        assert_eq!(recovered, Intent::SeekRelative { seconds: -5.0 });
299    }
300
301    #[test]
302    fn reassemble_unknown_name_errors() {
303        let err = Intent::reassemble("definitely_not_an_intent", &serde_json::json!({}));
304        assert!(err.is_err());
305    }
306}