1use std::collections::HashMap;
5
6use tokio::sync::RwLock;
7use uuid::Uuid;
8use weave_contracts::{FeedbackRule, Mapping, Route, TargetCandidate};
9
10use super::intent::{InputPrimitive, Intent};
11
12#[derive(Debug, Clone)]
14pub struct RoutedIntent {
15 pub service_type: String,
16 pub service_target: String,
17 pub intent: Intent,
18}
19
20#[derive(Debug, Clone)]
26pub struct SelectionMode {
27 pub mapping_id: Uuid,
28 pub edge_id: String,
29 pub candidates: Vec<TargetCandidate>,
30 pub cursor: usize,
31 rotation_accumulator: f64,
39}
40
41pub const SELECTION_ROTATION_STEP: f64 = 0.25;
48
49impl SelectionMode {
50 pub fn new(
51 mapping_id: Uuid,
52 edge_id: String,
53 candidates: Vec<TargetCandidate>,
54 cursor: usize,
55 ) -> Self {
56 Self {
57 mapping_id,
58 edge_id,
59 candidates,
60 cursor,
61 rotation_accumulator: 0.0,
62 }
63 }
64
65 fn current(&self) -> &TargetCandidate {
66 &self.candidates[self.cursor]
67 }
68
69 fn advance(&mut self, delta: f64) -> bool {
75 let n = self.candidates.len();
76 if n == 0 {
77 return false;
78 }
79 self.rotation_accumulator += delta;
80 let mut moved = false;
81 while self.rotation_accumulator >= SELECTION_ROTATION_STEP {
82 self.cursor = (self.cursor + 1) % n;
83 self.rotation_accumulator -= SELECTION_ROTATION_STEP;
84 moved = true;
85 }
86 while self.rotation_accumulator <= -SELECTION_ROTATION_STEP {
87 self.cursor = (self.cursor + n - 1) % n;
88 self.rotation_accumulator += SELECTION_ROTATION_STEP;
89 moved = true;
90 }
91 moved
92 }
93}
94
95#[derive(Debug, Clone)]
100pub enum RouteOutcome {
101 Normal(Vec<RoutedIntent>),
102 EnterSelection {
104 edge_id: String,
105 mapping_id: Uuid,
106 glyph: String,
107 },
108 UpdateSelection {
110 mapping_id: Uuid,
111 glyph: String,
112 },
113 CommitSelection {
116 edge_id: String,
117 mapping_id: Uuid,
118 service_target: String,
119 },
120 CancelSelection {
123 mapping_id: Uuid,
124 },
125}
126
127#[derive(Default)]
130pub struct RoutingEngine {
131 by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
132 selection: RwLock<HashMap<(String, String), SelectionMode>>,
133}
134
135impl RoutingEngine {
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 pub async fn replace_all(&self, mappings: Vec<Mapping>) {
142 let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
143 for m in mappings {
144 by_device
145 .entry((m.device_type.clone(), m.device_id.clone()))
146 .or_default()
147 .push(m);
148 }
149 *self.by_device.write().await = by_device;
150 }
151
152 pub async fn upsert_mapping(&self, mapping: Mapping) {
155 let mut guard = self.by_device.write().await;
156 for list in guard.values_mut() {
159 list.retain(|m| m.mapping_id != mapping.mapping_id);
160 }
161 guard
162 .entry((mapping.device_type.clone(), mapping.device_id.clone()))
163 .or_default()
164 .push(mapping);
165 }
166
167 pub async fn remove_mapping(&self, id: &uuid::Uuid) {
170 let mut guard = self.by_device.write().await;
171 for list in guard.values_mut() {
172 list.retain(|m| &m.mapping_id != id);
173 }
174 guard.retain(|_, list| !list.is_empty());
176 }
177
178 pub async fn snapshot(&self) -> Vec<Mapping> {
181 self.by_device
182 .read()
183 .await
184 .values()
185 .flatten()
186 .cloned()
187 .collect()
188 }
189
190 pub async fn feedback_rules_for_target(
200 &self,
201 service_type: &str,
202 target: &str,
203 ) -> Vec<FeedbackRule> {
204 let guard = self.by_device.read().await;
205 for list in guard.values() {
206 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
207 return rules;
208 }
209 }
210 Vec::new()
211 }
212
213 pub async fn feedback_rules_for_device_target(
225 &self,
226 device_type: &str,
227 device_id: &str,
228 service_type: &str,
229 target: &str,
230 ) -> Option<Vec<FeedbackRule>> {
231 let guard = self.by_device.read().await;
232 let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
233 mapping_rules_for_target(list, service_type, target)
234 }
235
236 pub async fn feedback_targets_for(
242 &self,
243 service_type: &str,
244 target: &str,
245 ) -> Vec<(String, String, Vec<FeedbackRule>)> {
246 let guard = self.by_device.read().await;
247 let mut out = Vec::new();
248 for ((device_type, device_id), list) in guard.iter() {
249 if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
250 out.push((device_type.clone(), device_id.clone(), rules));
251 }
252 }
253 out
254 }
255
256 pub async fn route(
259 &self,
260 device_type: &str,
261 device_id: &str,
262 input: &InputPrimitive,
263 ) -> Vec<RoutedIntent> {
264 let guard = self.by_device.read().await;
265 let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
266 return Vec::new();
267 };
268 route_mappings(mappings, input)
269 }
270
271 pub async fn route_with_mode(
276 &self,
277 device_type: &str,
278 device_id: &str,
279 input: &InputPrimitive,
280 ) -> RouteOutcome {
281 let key = (device_type.to_string(), device_id.to_string());
282
283 {
286 let mut sel = self.selection.write().await;
287 if let Some(mode) = sel.get_mut(&key) {
288 match input {
289 InputPrimitive::Rotate { delta } => {
290 if !mode.advance(*delta) {
295 return RouteOutcome::Normal(Vec::new());
296 }
297 let glyph = mode.current().glyph.clone();
298 let mapping_id = mode.mapping_id;
299 return RouteOutcome::UpdateSelection { mapping_id, glyph };
300 }
301 InputPrimitive::Press => {
302 let mapping_id = mode.mapping_id;
303 let service_target = mode.current().target.clone();
304 let edge_id = mode.edge_id.clone();
305 sel.remove(&key);
306 return RouteOutcome::CommitSelection {
307 edge_id,
308 mapping_id,
309 service_target,
310 };
311 }
312 _ => {
313 let mapping_id = mode.mapping_id;
314 sel.remove(&key);
315 return RouteOutcome::CancelSelection { mapping_id };
316 }
317 }
318 }
319 }
320
321 let guard = self.by_device.read().await;
324 let Some(mappings) = guard.get(&key) else {
325 return RouteOutcome::Normal(Vec::new());
326 };
327
328 for m in mappings {
329 if !m.active {
330 continue;
331 }
332 let Some(switch_on) = m.target_switch_on.as_deref() else {
333 continue;
334 };
335 if m.target_candidates.is_empty() {
336 continue;
337 }
338 if !input.matches_route(switch_on) {
339 continue;
340 }
341 let current_idx = m
345 .target_candidates
346 .iter()
347 .position(|c| c.target == m.service_target);
348 let cursor = match current_idx {
349 Some(i) => (i + 1) % m.target_candidates.len(),
350 None => 0,
351 };
352 let mode = SelectionMode::new(
353 m.mapping_id,
354 m.edge_id.clone(),
355 m.target_candidates.clone(),
356 cursor,
357 );
358 let glyph = mode.current().glyph.clone();
359 let mapping_id = mode.mapping_id;
360 let edge_id = mode.edge_id.clone();
361 drop(guard);
362 self.selection.write().await.insert(key, mode);
363 return RouteOutcome::EnterSelection {
364 edge_id,
365 mapping_id,
366 glyph,
367 };
368 }
369
370 RouteOutcome::Normal(route_mappings(mappings, input))
371 }
372}
373
374fn mapping_rules_for_target(
379 list: &[Mapping],
380 service_type: &str,
381 target: &str,
382) -> Option<Vec<FeedbackRule>> {
383 for m in list {
384 if m.service_type == service_type && m.service_target == target {
385 return Some(m.feedback.clone());
386 }
387 for c in &m.target_candidates {
388 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
389 if c_service_type == service_type && c.target == target {
390 return Some(m.feedback.clone());
391 }
392 }
393 }
394 None
395}
396
397fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
398 let mut out = Vec::new();
399 for m in mappings {
400 if !m.active {
401 continue;
402 }
403 let (service_type, routes) = m.effective_for(&m.service_target);
409 for route in routes {
410 if !input.matches_route(&route.input) {
411 continue;
412 }
413 if let Some(intent) = build_intent(route, input) {
414 out.push(RoutedIntent {
415 service_type: service_type.to_string(),
416 service_target: m.service_target.clone(),
417 intent,
418 });
419 break;
421 }
422 }
423 }
424 out
425}
426
427fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
428 let damping = route
429 .params
430 .get("damping")
431 .and_then(|v| v.as_f64())
432 .unwrap_or(1.0);
433
434 match route.intent.as_str() {
435 "play" => Some(Intent::Play),
436 "pause" => Some(Intent::Pause),
437 "play_pause" | "playpause" => Some(Intent::PlayPause),
438 "stop" => Some(Intent::Stop),
439 "next" => Some(Intent::Next),
440 "previous" => Some(Intent::Previous),
441 "mute" => Some(Intent::Mute),
442 "unmute" => Some(Intent::Unmute),
443 "power_toggle" => Some(Intent::PowerToggle),
444 "power_on" => Some(Intent::PowerOn),
445 "power_off" => Some(Intent::PowerOff),
446 "volume_change" => input
447 .continuous_value()
448 .map(|v| Intent::VolumeChange { delta: v * damping }),
449 "volume_set" => route
450 .params
451 .get("value")
452 .and_then(|v| v.as_f64())
453 .map(|value| Intent::VolumeSet { value }),
454 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
455 seconds: v * damping,
456 }),
457 "brightness_change" => input
458 .continuous_value()
459 .map(|v| Intent::BrightnessChange { delta: v * damping }),
460 "color_temperature_change" => input
461 .continuous_value()
462 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
463 _ => None,
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::Direction;
471 use std::collections::BTreeMap;
472 use uuid::Uuid;
473 use weave_contracts::Route;
474
475 fn rotate_mapping() -> Mapping {
476 Mapping {
477 mapping_id: Uuid::new_v4(),
478 edge_id: "living-room".into(),
479 device_type: "nuimo".into(),
480 device_id: "C3:81:DF:4E".into(),
481 service_type: "roon".into(),
482 service_target: "zone-1".into(),
483 routes: vec![Route {
484 input: "rotate".into(),
485 intent: "volume_change".into(),
486 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
487 }],
488 feedback: vec![],
489 active: true,
490 target_candidates: vec![],
491 target_switch_on: None,
492 }
493 }
494
495 #[tokio::test]
496 async fn rotate_produces_volume_change_with_damping() {
497 let engine = RoutingEngine::new();
498 engine.replace_all(vec![rotate_mapping()]).await;
499
500 let out = engine
501 .route(
502 "nuimo",
503 "C3:81:DF:4E",
504 &InputPrimitive::Rotate { delta: 0.03 },
505 )
506 .await;
507 assert_eq!(out.len(), 1);
508 match &out[0].intent {
509 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
510 other => panic!("expected VolumeChange, got {:?}", other),
511 }
512 assert_eq!(out[0].service_type, "roon");
513 assert_eq!(out[0].service_target, "zone-1");
514 }
515
516 #[tokio::test]
517 async fn inactive_mappings_are_skipped() {
518 let mut m = rotate_mapping();
519 m.active = false;
520 let engine = RoutingEngine::new();
521 engine.replace_all(vec![m]).await;
522
523 let out = engine
524 .route(
525 "nuimo",
526 "C3:81:DF:4E",
527 &InputPrimitive::Rotate { delta: 0.03 },
528 )
529 .await;
530 assert!(out.is_empty());
531 }
532
533 #[tokio::test]
534 async fn unknown_device_returns_empty() {
535 let engine = RoutingEngine::new();
536 engine.replace_all(vec![rotate_mapping()]).await;
537
538 let out = engine
539 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
540 .await;
541 assert!(out.is_empty());
542 }
543
544 fn selection_mapping() -> Mapping {
545 let mut m = rotate_mapping();
546 m.service_target = "target-A".into();
547 m.target_switch_on = Some("swipe_up".into());
548 m.target_candidates = vec![
549 TargetCandidate {
550 target: "target-A".into(),
551 label: "A".into(),
552 glyph: "glyph-a".into(),
553 service_type: None,
554 routes: None,
555 },
556 TargetCandidate {
557 target: "target-B".into(),
558 label: "B".into(),
559 glyph: "glyph-b".into(),
560 service_type: None,
561 routes: None,
562 },
563 ];
564 m
565 }
566
567 fn cross_service_mapping() -> Mapping {
571 let mut m = rotate_mapping();
572 m.service_target = "roon-zone".into();
573 m.target_switch_on = Some("long_press".into());
574 m.target_candidates = vec![
575 TargetCandidate {
576 target: "roon-zone".into(),
577 label: "Roon".into(),
578 glyph: "roon".into(),
579 service_type: None,
580 routes: None,
581 },
582 TargetCandidate {
583 target: "hue-light".into(),
584 label: "Hue".into(),
585 glyph: "hue".into(),
586 service_type: Some("hue".into()),
587 routes: Some(vec![Route {
588 input: "rotate".into(),
589 intent: "brightness_change".into(),
590 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
591 }]),
592 },
593 ];
594 m
595 }
596
597 #[test]
598 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
599 let m = cross_service_mapping();
600 let (svc, routes) = m.effective_for("roon-zone");
601 assert_eq!(svc, "roon");
602 assert_eq!(routes.len(), 1);
603 assert_eq!(routes[0].intent, "volume_change");
604 }
605
606 #[test]
607 fn effective_for_returns_candidate_overrides_when_present() {
608 let m = cross_service_mapping();
609 let (svc, routes) = m.effective_for("hue-light");
610 assert_eq!(svc, "hue");
611 assert_eq!(routes.len(), 1);
612 assert_eq!(routes[0].intent, "brightness_change");
613 }
614
615 #[tokio::test]
616 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
617 let mut m = cross_service_mapping();
618 m.service_target = "hue-light".into();
621
622 let engine = RoutingEngine::new();
623 engine.replace_all(vec![m]).await;
624
625 let out = engine
626 .route(
627 "nuimo",
628 "C3:81:DF:4E",
629 &InputPrimitive::Rotate { delta: 0.1 },
630 )
631 .await;
632 assert_eq!(out.len(), 1);
633 assert_eq!(out[0].service_type, "hue");
634 assert_eq!(out[0].service_target, "hue-light");
635 match &out[0].intent {
636 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
637 other => panic!("expected BrightnessChange, got {:?}", other),
638 }
639 }
640
641 #[tokio::test]
642 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
643 let engine = RoutingEngine::new();
644 engine.replace_all(vec![selection_mapping()]).await;
645
646 match engine
647 .route_with_mode(
648 "nuimo",
649 "C3:81:DF:4E",
650 &InputPrimitive::Swipe {
651 direction: Direction::Up,
652 },
653 )
654 .await
655 {
656 RouteOutcome::EnterSelection { glyph, .. } => {
657 assert_eq!(glyph, "glyph-b");
659 }
660 other => panic!("expected EnterSelection, got {:?}", other),
661 }
662
663 match engine
664 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
665 .await
666 {
667 RouteOutcome::CommitSelection { service_target, .. } => {
668 assert_eq!(service_target, "target-B")
669 }
670 other => panic!("expected CommitSelection, got {:?}", other),
671 }
672 }
673
674 #[tokio::test]
675 async fn rotate_then_press_commits_advanced_candidate() {
676 let engine = RoutingEngine::new();
677 engine.replace_all(vec![selection_mapping()]).await;
678
679 let _ = engine
681 .route_with_mode(
682 "nuimo",
683 "C3:81:DF:4E",
684 &InputPrimitive::Swipe {
685 direction: Direction::Up,
686 },
687 )
688 .await;
689 match engine
691 .route_with_mode(
692 "nuimo",
693 "C3:81:DF:4E",
694 &InputPrimitive::Rotate {
695 delta: SELECTION_ROTATION_STEP,
696 },
697 )
698 .await
699 {
700 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
701 other => panic!("expected UpdateSelection, got {:?}", other),
702 }
703 match engine
704 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
705 .await
706 {
707 RouteOutcome::CommitSelection { service_target, .. } => {
708 assert_eq!(service_target, "target-A")
709 }
710 other => panic!("expected CommitSelection, got {:?}", other),
711 }
712 }
713
714 #[tokio::test]
715 async fn sub_threshold_rotate_keeps_cursor_still() {
716 let engine = RoutingEngine::new();
717 engine.replace_all(vec![selection_mapping()]).await;
718
719 let _ = engine
721 .route_with_mode(
722 "nuimo",
723 "C3:81:DF:4E",
724 &InputPrimitive::Swipe {
725 direction: Direction::Up,
726 },
727 )
728 .await;
729
730 match engine
734 .route_with_mode(
735 "nuimo",
736 "C3:81:DF:4E",
737 &InputPrimitive::Rotate {
738 delta: SELECTION_ROTATION_STEP / 2.0,
739 },
740 )
741 .await
742 {
743 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
744 other => panic!("expected Normal(empty), got {:?}", other),
745 }
746
747 match engine
750 .route_with_mode(
751 "nuimo",
752 "C3:81:DF:4E",
753 &InputPrimitive::Rotate {
754 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
755 },
756 )
757 .await
758 {
759 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
760 other => panic!("expected UpdateSelection, got {:?}", other),
761 }
762 }
763
764 #[tokio::test]
765 async fn large_rotate_produces_single_step_per_outcome() {
766 let engine = RoutingEngine::new();
771 engine.replace_all(vec![selection_mapping()]).await;
772
773 let _ = engine
774 .route_with_mode(
775 "nuimo",
776 "C3:81:DF:4E",
777 &InputPrimitive::Swipe {
778 direction: Direction::Up,
779 },
780 )
781 .await;
782 match engine
786 .route_with_mode(
787 "nuimo",
788 "C3:81:DF:4E",
789 &InputPrimitive::Rotate {
790 delta: SELECTION_ROTATION_STEP * 2.0,
791 },
792 )
793 .await
794 {
795 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
796 other => panic!("expected UpdateSelection, got {:?}", other),
797 }
798 }
799
800 fn playback_glyph_rule() -> FeedbackRule {
801 FeedbackRule {
802 state: "playback".into(),
803 feedback_type: "glyph".into(),
804 mapping: serde_json::json!({
805 "playing": "play",
806 "paused": "pause",
807 "stopped": "pause",
808 }),
809 }
810 }
811
812 fn mapping_with_feedback() -> Mapping {
813 let mut m = rotate_mapping();
814 m.feedback = vec![playback_glyph_rule()];
815 m
816 }
817
818 #[tokio::test]
819 async fn feedback_rules_match_primary_target() {
820 let engine = RoutingEngine::new();
821 engine.replace_all(vec![mapping_with_feedback()]).await;
822
823 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
824 assert_eq!(rules.len(), 1);
825 assert_eq!(rules[0].state, "playback");
826 }
827
828 #[tokio::test]
829 async fn feedback_rules_match_candidate_override_service_type() {
830 let mut m = mapping_with_feedback();
831 m.target_candidates = vec![TargetCandidate {
832 target: "hue-light-1".into(),
833 label: "Living".into(),
834 glyph: "link".into(),
835 service_type: Some("hue".into()),
836 routes: None,
837 }];
838 let engine = RoutingEngine::new();
839 engine.replace_all(vec![m]).await;
840
841 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
842 assert_eq!(
843 rules.len(),
844 1,
845 "candidate with overridden service_type inherits mapping feedback",
846 );
847 }
848
849 #[tokio::test]
850 async fn feedback_rules_empty_for_unknown_target() {
851 let engine = RoutingEngine::new();
852 engine.replace_all(vec![mapping_with_feedback()]).await;
853
854 let rules = engine
855 .feedback_rules_for_target("roon", "some-other-zone")
856 .await;
857 assert!(rules.is_empty());
858 }
859
860 #[tokio::test]
861 async fn feedback_rules_for_device_target_scopes_by_device() {
862 let mut m_a = mapping_with_feedback();
865 m_a.device_id = "nuimo-a".into();
866 let mut m_b = mapping_with_feedback();
867 m_b.mapping_id = Uuid::new_v4();
868 m_b.device_id = "nuimo-b".into();
869 m_b.service_target = "zone-b".into();
870
871 let engine = RoutingEngine::new();
872 engine.replace_all(vec![m_a, m_b]).await;
873
874 let rules_a = engine
875 .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
876 .await;
877 let rules_a = rules_a.expect("nuimo-a owns zone-1");
878 assert_eq!(rules_a.len(), 1);
879
880 let rules_b_wrong = engine
881 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
882 .await;
883 assert!(
884 rules_b_wrong.is_none(),
885 "nuimo-b does not own zone-1 — must skip entirely",
886 );
887
888 let rules_b_right = engine
889 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
890 .await;
891 let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
892 assert_eq!(rules_b_right.len(), 1);
893 }
894
895 #[tokio::test]
896 async fn feedback_rules_for_device_target_none_for_unknown_device() {
897 let engine = RoutingEngine::new();
898 engine.replace_all(vec![mapping_with_feedback()]).await;
899
900 let rules = engine
901 .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
902 .await;
903 assert!(
904 rules.is_none(),
905 "no mapping for unknown device — caller should skip",
906 );
907 }
908
909 #[tokio::test]
910 async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
911 let m = rotate_mapping(); let engine = RoutingEngine::new();
916 engine.replace_all(vec![m]).await;
917
918 let rules = engine
919 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
920 .await;
921 let rules = rules.expect("mapping exists — caller should fall back to defaults");
922 assert!(
923 rules.is_empty(),
924 "no explicit rules configured — pump will use FeedbackPlan defaults",
925 );
926 }
927}