use std::collections::HashMap;
use tokio::sync::RwLock;
use uuid::Uuid;
use weave_contracts::{FeedbackRule, Mapping, Route, TargetCandidate};
use super::intent::{InputPrimitive, Intent};
#[derive(Debug, Clone)]
pub struct RoutedIntent {
pub service_type: String,
pub service_target: String,
pub intent: Intent,
}
#[derive(Debug, Clone)]
pub struct SelectionMode {
pub mapping_id: Uuid,
pub edge_id: String,
pub candidates: Vec<TargetCandidate>,
pub cursor: usize,
rotation_accumulator: f64,
}
pub const SELECTION_ROTATION_STEP: f64 = 0.25;
impl SelectionMode {
pub fn new(
mapping_id: Uuid,
edge_id: String,
candidates: Vec<TargetCandidate>,
cursor: usize,
) -> Self {
Self {
mapping_id,
edge_id,
candidates,
cursor,
rotation_accumulator: 0.0,
}
}
fn current(&self) -> &TargetCandidate {
&self.candidates[self.cursor]
}
fn advance(&mut self, delta: f64) -> bool {
let n = self.candidates.len();
if n == 0 {
return false;
}
self.rotation_accumulator += delta;
let mut moved = false;
while self.rotation_accumulator >= SELECTION_ROTATION_STEP {
self.cursor = (self.cursor + 1) % n;
self.rotation_accumulator -= SELECTION_ROTATION_STEP;
moved = true;
}
while self.rotation_accumulator <= -SELECTION_ROTATION_STEP {
self.cursor = (self.cursor + n - 1) % n;
self.rotation_accumulator += SELECTION_ROTATION_STEP;
moved = true;
}
moved
}
}
#[derive(Debug, Clone)]
pub enum RouteOutcome {
Normal(Vec<RoutedIntent>),
EnterSelection {
edge_id: String,
mapping_id: Uuid,
glyph: String,
},
UpdateSelection {
mapping_id: Uuid,
glyph: String,
},
CommitSelection {
edge_id: String,
mapping_id: Uuid,
service_target: String,
},
CancelSelection {
mapping_id: Uuid,
},
}
#[derive(Default)]
pub struct RoutingEngine {
by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
selection: RwLock<HashMap<(String, String), SelectionMode>>,
}
impl RoutingEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn replace_all(&self, mappings: Vec<Mapping>) {
let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
for m in mappings {
by_device
.entry((m.device_type.clone(), m.device_id.clone()))
.or_default()
.push(m);
}
*self.by_device.write().await = by_device;
}
pub async fn upsert_mapping(&self, mapping: Mapping) {
let mut guard = self.by_device.write().await;
for list in guard.values_mut() {
list.retain(|m| m.mapping_id != mapping.mapping_id);
}
guard
.entry((mapping.device_type.clone(), mapping.device_id.clone()))
.or_default()
.push(mapping);
}
pub async fn remove_mapping(&self, id: &uuid::Uuid) {
let mut guard = self.by_device.write().await;
for list in guard.values_mut() {
list.retain(|m| &m.mapping_id != id);
}
guard.retain(|_, list| !list.is_empty());
}
pub async fn snapshot(&self) -> Vec<Mapping> {
self.by_device
.read()
.await
.values()
.flatten()
.cloned()
.collect()
}
pub async fn feedback_rules_for_target(
&self,
service_type: &str,
target: &str,
) -> Vec<FeedbackRule> {
let guard = self.by_device.read().await;
for list in guard.values() {
for m in list {
if m.service_type == service_type && m.service_target == target {
return m.feedback.clone();
}
for c in &m.target_candidates {
let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
if c_service_type == service_type && c.target == target {
return m.feedback.clone();
}
}
}
}
Vec::new()
}
pub async fn route(
&self,
device_type: &str,
device_id: &str,
input: &InputPrimitive,
) -> Vec<RoutedIntent> {
let guard = self.by_device.read().await;
let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
return Vec::new();
};
route_mappings(mappings, input)
}
pub async fn route_with_mode(
&self,
device_type: &str,
device_id: &str,
input: &InputPrimitive,
) -> RouteOutcome {
let key = (device_type.to_string(), device_id.to_string());
{
let mut sel = self.selection.write().await;
if let Some(mode) = sel.get_mut(&key) {
match input {
InputPrimitive::Rotate { delta } => {
if !mode.advance(*delta) {
return RouteOutcome::Normal(Vec::new());
}
let glyph = mode.current().glyph.clone();
let mapping_id = mode.mapping_id;
return RouteOutcome::UpdateSelection { mapping_id, glyph };
}
InputPrimitive::Press => {
let mapping_id = mode.mapping_id;
let service_target = mode.current().target.clone();
let edge_id = mode.edge_id.clone();
sel.remove(&key);
return RouteOutcome::CommitSelection {
edge_id,
mapping_id,
service_target,
};
}
_ => {
let mapping_id = mode.mapping_id;
sel.remove(&key);
return RouteOutcome::CancelSelection { mapping_id };
}
}
}
}
let guard = self.by_device.read().await;
let Some(mappings) = guard.get(&key) else {
return RouteOutcome::Normal(Vec::new());
};
for m in mappings {
if !m.active {
continue;
}
let Some(switch_on) = m.target_switch_on.as_deref() else {
continue;
};
if m.target_candidates.is_empty() {
continue;
}
if !input.matches_route(switch_on) {
continue;
}
let current_idx = m
.target_candidates
.iter()
.position(|c| c.target == m.service_target);
let cursor = match current_idx {
Some(i) => (i + 1) % m.target_candidates.len(),
None => 0,
};
let mode = SelectionMode::new(
m.mapping_id,
m.edge_id.clone(),
m.target_candidates.clone(),
cursor,
);
let glyph = mode.current().glyph.clone();
let mapping_id = mode.mapping_id;
let edge_id = mode.edge_id.clone();
drop(guard);
self.selection.write().await.insert(key, mode);
return RouteOutcome::EnterSelection {
edge_id,
mapping_id,
glyph,
};
}
RouteOutcome::Normal(route_mappings(mappings, input))
}
}
fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
let mut out = Vec::new();
for m in mappings {
if !m.active {
continue;
}
let (service_type, routes) = m.effective_for(&m.service_target);
for route in routes {
if !input.matches_route(&route.input) {
continue;
}
if let Some(intent) = build_intent(route, input) {
out.push(RoutedIntent {
service_type: service_type.to_string(),
service_target: m.service_target.clone(),
intent,
});
break;
}
}
}
out
}
fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
let damping = route
.params
.get("damping")
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
match route.intent.as_str() {
"play" => Some(Intent::Play),
"pause" => Some(Intent::Pause),
"play_pause" | "playpause" => Some(Intent::PlayPause),
"stop" => Some(Intent::Stop),
"next" => Some(Intent::Next),
"previous" => Some(Intent::Previous),
"mute" => Some(Intent::Mute),
"unmute" => Some(Intent::Unmute),
"power_toggle" => Some(Intent::PowerToggle),
"power_on" => Some(Intent::PowerOn),
"power_off" => Some(Intent::PowerOff),
"volume_change" => input
.continuous_value()
.map(|v| Intent::VolumeChange { delta: v * damping }),
"volume_set" => route
.params
.get("value")
.and_then(|v| v.as_f64())
.map(|value| Intent::VolumeSet { value }),
"seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
seconds: v * damping,
}),
"brightness_change" => input
.continuous_value()
.map(|v| Intent::BrightnessChange { delta: v * damping }),
"color_temperature_change" => input
.continuous_value()
.map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Direction;
use std::collections::BTreeMap;
use uuid::Uuid;
use weave_contracts::Route;
fn rotate_mapping() -> Mapping {
Mapping {
mapping_id: Uuid::new_v4(),
edge_id: "living-room".into(),
device_type: "nuimo".into(),
device_id: "C3:81:DF:4E".into(),
service_type: "roon".into(),
service_target: "zone-1".into(),
routes: vec![Route {
input: "rotate".into(),
intent: "volume_change".into(),
params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
}],
feedback: vec![],
active: true,
target_candidates: vec![],
target_switch_on: None,
}
}
#[tokio::test]
async fn rotate_produces_volume_change_with_damping() {
let engine = RoutingEngine::new();
engine.replace_all(vec![rotate_mapping()]).await;
let out = engine
.route(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate { delta: 0.03 },
)
.await;
assert_eq!(out.len(), 1);
match &out[0].intent {
Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
other => panic!("expected VolumeChange, got {:?}", other),
}
assert_eq!(out[0].service_type, "roon");
assert_eq!(out[0].service_target, "zone-1");
}
#[tokio::test]
async fn inactive_mappings_are_skipped() {
let mut m = rotate_mapping();
m.active = false;
let engine = RoutingEngine::new();
engine.replace_all(vec![m]).await;
let out = engine
.route(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate { delta: 0.03 },
)
.await;
assert!(out.is_empty());
}
#[tokio::test]
async fn unknown_device_returns_empty() {
let engine = RoutingEngine::new();
engine.replace_all(vec![rotate_mapping()]).await;
let out = engine
.route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
.await;
assert!(out.is_empty());
}
fn selection_mapping() -> Mapping {
let mut m = rotate_mapping();
m.service_target = "target-A".into();
m.target_switch_on = Some("swipe_up".into());
m.target_candidates = vec![
TargetCandidate {
target: "target-A".into(),
label: "A".into(),
glyph: "glyph-a".into(),
service_type: None,
routes: None,
},
TargetCandidate {
target: "target-B".into(),
label: "B".into(),
glyph: "glyph-b".into(),
service_type: None,
routes: None,
},
];
m
}
fn cross_service_mapping() -> Mapping {
let mut m = rotate_mapping();
m.service_target = "roon-zone".into();
m.target_switch_on = Some("long_press".into());
m.target_candidates = vec![
TargetCandidate {
target: "roon-zone".into(),
label: "Roon".into(),
glyph: "roon".into(),
service_type: None,
routes: None,
},
TargetCandidate {
target: "hue-light".into(),
label: "Hue".into(),
glyph: "hue".into(),
service_type: Some("hue".into()),
routes: Some(vec![Route {
input: "rotate".into(),
intent: "brightness_change".into(),
params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
}]),
},
];
m
}
#[test]
fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
let m = cross_service_mapping();
let (svc, routes) = m.effective_for("roon-zone");
assert_eq!(svc, "roon");
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].intent, "volume_change");
}
#[test]
fn effective_for_returns_candidate_overrides_when_present() {
let m = cross_service_mapping();
let (svc, routes) = m.effective_for("hue-light");
assert_eq!(svc, "hue");
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].intent, "brightness_change");
}
#[tokio::test]
async fn rotate_on_cross_service_candidate_produces_hue_intent() {
let mut m = cross_service_mapping();
m.service_target = "hue-light".into();
let engine = RoutingEngine::new();
engine.replace_all(vec![m]).await;
let out = engine
.route(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate { delta: 0.1 },
)
.await;
assert_eq!(out.len(), 1);
assert_eq!(out[0].service_type, "hue");
assert_eq!(out[0].service_target, "hue-light");
match &out[0].intent {
Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
other => panic!("expected BrightnessChange, got {:?}", other),
}
}
#[tokio::test]
async fn swipe_up_press_cycles_to_next_target_without_rotate() {
let engine = RoutingEngine::new();
engine.replace_all(vec![selection_mapping()]).await;
match engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Swipe {
direction: Direction::Up,
},
)
.await
{
RouteOutcome::EnterSelection { glyph, .. } => {
assert_eq!(glyph, "glyph-b");
}
other => panic!("expected EnterSelection, got {:?}", other),
}
match engine
.route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
.await
{
RouteOutcome::CommitSelection { service_target, .. } => {
assert_eq!(service_target, "target-B")
}
other => panic!("expected CommitSelection, got {:?}", other),
}
}
#[tokio::test]
async fn rotate_then_press_commits_advanced_candidate() {
let engine = RoutingEngine::new();
engine.replace_all(vec![selection_mapping()]).await;
let _ = engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Swipe {
direction: Direction::Up,
},
)
.await;
match engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate {
delta: SELECTION_ROTATION_STEP,
},
)
.await
{
RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
other => panic!("expected UpdateSelection, got {:?}", other),
}
match engine
.route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
.await
{
RouteOutcome::CommitSelection { service_target, .. } => {
assert_eq!(service_target, "target-A")
}
other => panic!("expected CommitSelection, got {:?}", other),
}
}
#[tokio::test]
async fn sub_threshold_rotate_keeps_cursor_still() {
let engine = RoutingEngine::new();
engine.replace_all(vec![selection_mapping()]).await;
let _ = engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Swipe {
direction: Direction::Up,
},
)
.await;
match engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate {
delta: SELECTION_ROTATION_STEP / 2.0,
},
)
.await
{
RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
other => panic!("expected Normal(empty), got {:?}", other),
}
match engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate {
delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
},
)
.await
{
RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
other => panic!("expected UpdateSelection, got {:?}", other),
}
}
#[tokio::test]
async fn large_rotate_produces_single_step_per_outcome() {
let engine = RoutingEngine::new();
engine.replace_all(vec![selection_mapping()]).await;
let _ = engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Swipe {
direction: Direction::Up,
},
)
.await;
match engine
.route_with_mode(
"nuimo",
"C3:81:DF:4E",
&InputPrimitive::Rotate {
delta: SELECTION_ROTATION_STEP * 2.0,
},
)
.await
{
RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
other => panic!("expected UpdateSelection, got {:?}", other),
}
}
fn playback_glyph_rule() -> FeedbackRule {
FeedbackRule {
state: "playback".into(),
feedback_type: "glyph".into(),
mapping: serde_json::json!({
"playing": "play",
"paused": "pause",
"stopped": "pause",
}),
}
}
fn mapping_with_feedback() -> Mapping {
let mut m = rotate_mapping();
m.feedback = vec![playback_glyph_rule()];
m
}
#[tokio::test]
async fn feedback_rules_match_primary_target() {
let engine = RoutingEngine::new();
engine.replace_all(vec![mapping_with_feedback()]).await;
let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].state, "playback");
}
#[tokio::test]
async fn feedback_rules_match_candidate_override_service_type() {
let mut m = mapping_with_feedback();
m.target_candidates = vec![TargetCandidate {
target: "hue-light-1".into(),
label: "Living".into(),
glyph: "link".into(),
service_type: Some("hue".into()),
routes: None,
}];
let engine = RoutingEngine::new();
engine.replace_all(vec![m]).await;
let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
assert_eq!(
rules.len(),
1,
"candidate with overridden service_type inherits mapping feedback",
);
}
#[tokio::test]
async fn feedback_rules_empty_for_unknown_target() {
let engine = RoutingEngine::new();
engine.replace_all(vec![mapping_with_feedback()]).await;
let rules = engine
.feedback_rules_for_target("roon", "some-other-zone")
.await;
assert!(rules.is_empty());
}
}