use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum InputPrimitive {
Rotate {
delta: f64,
},
Press,
Release,
LongPress,
Swipe {
direction: Direction,
},
Slide {
value: f64,
},
Hover {
proximity: f64,
},
Touch {
area: TouchArea,
},
LongTouch {
area: TouchArea,
},
KeyPress {
key: u32,
},
Button {
id: u8,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TouchArea {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Intent {
Play,
Pause,
PlayPause,
Stop,
Next,
Previous,
VolumeChange { delta: f64 },
VolumeSet { value: f64 },
Mute,
Unmute,
SeekRelative { seconds: f64 },
SeekAbsolute { seconds: f64 },
BrightnessChange { delta: f64 },
BrightnessSet { value: f64 },
ColorTemperatureChange { delta: f64 },
PowerToggle,
PowerOn,
PowerOff,
}
impl Intent {
pub fn split(&self) -> (String, serde_json::Value) {
match serde_json::to_value(self) {
Ok(serde_json::Value::Object(mut map)) => {
let name = map
.remove("type")
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string());
(name, serde_json::Value::Object(map))
}
_ => ("unknown".to_string(), serde_json::json!({})),
}
}
pub fn reassemble(intent: &str, params: &serde_json::Value) -> Result<Self, serde_json::Error> {
let value = match params {
serde_json::Value::Object(map) => {
let mut m = map.clone();
m.insert(
"type".to_string(),
serde_json::Value::String(intent.to_string()),
);
serde_json::Value::Object(m)
}
_ => serde_json::json!({ "type": intent }),
};
serde_json::from_value(value)
}
}
impl InputPrimitive {
pub fn matches_route(&self, route_input: &str) -> bool {
match (self, route_input) {
(InputPrimitive::Rotate { .. }, "rotate") => true,
(InputPrimitive::Press, "press") => true,
(InputPrimitive::Release, "release") => true,
(InputPrimitive::LongPress, "long_press") => true,
(InputPrimitive::Slide { .. }, "slide") => true,
(InputPrimitive::Hover { .. }, "hover") => true,
(InputPrimitive::Swipe { direction }, s) => matches!(
(direction, s),
(Direction::Up, "swipe_up")
| (Direction::Down, "swipe_down")
| (Direction::Left, "swipe_left")
| (Direction::Right, "swipe_right")
),
(InputPrimitive::Touch { area }, s) => matches!(
(area, s),
(TouchArea::Top, "touch_top")
| (TouchArea::Bottom, "touch_bottom")
| (TouchArea::Left, "touch_left")
| (TouchArea::Right, "touch_right")
),
(InputPrimitive::LongTouch { area }, s) => matches!(
(area, s),
(TouchArea::Top, "long_touch_top")
| (TouchArea::Bottom, "long_touch_bottom")
| (TouchArea::Left, "long_touch_left")
| (TouchArea::Right, "long_touch_right")
),
(InputPrimitive::Button { id }, s) => s
.strip_prefix("button_")
.and_then(|n| n.parse::<u8>().ok())
.is_some_and(|parsed| parsed == *id),
_ => false,
}
}
pub fn continuous_value(&self) -> Option<f64> {
match self {
InputPrimitive::Rotate { delta } | InputPrimitive::Slide { value: delta } => {
Some(*delta)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotate_matches_rotate_route() {
let r = InputPrimitive::Rotate { delta: 0.03 };
assert!(r.matches_route("rotate"));
assert!(!r.matches_route("press"));
}
#[test]
fn swipe_direction_matters() {
let s = InputPrimitive::Swipe {
direction: Direction::Right,
};
assert!(s.matches_route("swipe_right"));
assert!(!s.matches_route("swipe_left"));
assert!(!s.matches_route("swipe_up"));
}
#[test]
fn button_id_matches_numbered_route() {
let b = InputPrimitive::Button { id: 1 };
assert!(b.matches_route("button_1"));
assert!(!b.matches_route("button_2"));
assert!(!b.matches_route("button_"));
assert!(!b.matches_route("press"));
}
#[test]
fn button_route_rejects_non_numeric_suffix() {
let b = InputPrimitive::Button { id: 3 };
assert!(!b.matches_route("button_x"));
assert!(!b.matches_route("button_3a"));
assert!(b.matches_route("button_3"));
}
#[test]
fn split_payloadless_intent_yields_empty_params() {
let (name, params) = Intent::PlayPause.split();
assert_eq!(name, "play_pause");
assert_eq!(params, serde_json::json!({}));
}
#[test]
fn split_continuous_intent_carries_params() {
let (name, params) = Intent::VolumeChange { delta: 0.25 }.split();
assert_eq!(name, "volume_change");
assert_eq!(params, serde_json::json!({ "delta": 0.25 }));
}
#[test]
fn reassemble_payloadless_round_trips() {
let (name, params) = Intent::Play.split();
let recovered = Intent::reassemble(&name, ¶ms).unwrap();
assert_eq!(recovered, Intent::Play);
}
#[test]
fn reassemble_continuous_round_trips() {
let (name, params) = Intent::SeekRelative { seconds: -5.0 }.split();
let recovered = Intent::reassemble(&name, ¶ms).unwrap();
assert_eq!(recovered, Intent::SeekRelative { seconds: -5.0 });
}
#[test]
fn reassemble_unknown_name_errors() {
let err = Intent::reassemble("definitely_not_an_intent", &serde_json::json!({}));
assert!(err.is_err());
}
}