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 route(
239 &self,
240 device_type: &str,
241 device_id: &str,
242 input: &InputPrimitive,
243 ) -> Vec<RoutedIntent> {
244 let guard = self.by_device.read().await;
245 let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
246 return Vec::new();
247 };
248 route_mappings(mappings, input)
249 }
250
251 pub async fn route_with_mode(
256 &self,
257 device_type: &str,
258 device_id: &str,
259 input: &InputPrimitive,
260 ) -> RouteOutcome {
261 let key = (device_type.to_string(), device_id.to_string());
262
263 {
266 let mut sel = self.selection.write().await;
267 if let Some(mode) = sel.get_mut(&key) {
268 match input {
269 InputPrimitive::Rotate { delta } => {
270 if !mode.advance(*delta) {
275 return RouteOutcome::Normal(Vec::new());
276 }
277 let glyph = mode.current().glyph.clone();
278 let mapping_id = mode.mapping_id;
279 return RouteOutcome::UpdateSelection { mapping_id, glyph };
280 }
281 InputPrimitive::Press => {
282 let mapping_id = mode.mapping_id;
283 let service_target = mode.current().target.clone();
284 let edge_id = mode.edge_id.clone();
285 sel.remove(&key);
286 return RouteOutcome::CommitSelection {
287 edge_id,
288 mapping_id,
289 service_target,
290 };
291 }
292 _ => {
293 let mapping_id = mode.mapping_id;
294 sel.remove(&key);
295 return RouteOutcome::CancelSelection { mapping_id };
296 }
297 }
298 }
299 }
300
301 let guard = self.by_device.read().await;
304 let Some(mappings) = guard.get(&key) else {
305 return RouteOutcome::Normal(Vec::new());
306 };
307
308 for m in mappings {
309 if !m.active {
310 continue;
311 }
312 let Some(switch_on) = m.target_switch_on.as_deref() else {
313 continue;
314 };
315 if m.target_candidates.is_empty() {
316 continue;
317 }
318 if !input.matches_route(switch_on) {
319 continue;
320 }
321 let current_idx = m
325 .target_candidates
326 .iter()
327 .position(|c| c.target == m.service_target);
328 let cursor = match current_idx {
329 Some(i) => (i + 1) % m.target_candidates.len(),
330 None => 0,
331 };
332 let mode = SelectionMode::new(
333 m.mapping_id,
334 m.edge_id.clone(),
335 m.target_candidates.clone(),
336 cursor,
337 );
338 let glyph = mode.current().glyph.clone();
339 let mapping_id = mode.mapping_id;
340 let edge_id = mode.edge_id.clone();
341 drop(guard);
342 self.selection.write().await.insert(key, mode);
343 return RouteOutcome::EnterSelection {
344 edge_id,
345 mapping_id,
346 glyph,
347 };
348 }
349
350 RouteOutcome::Normal(route_mappings(mappings, input))
351 }
352}
353
354fn mapping_rules_for_target(
359 list: &[Mapping],
360 service_type: &str,
361 target: &str,
362) -> Option<Vec<FeedbackRule>> {
363 for m in list {
364 if m.service_type == service_type && m.service_target == target {
365 return Some(m.feedback.clone());
366 }
367 for c in &m.target_candidates {
368 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
369 if c_service_type == service_type && c.target == target {
370 return Some(m.feedback.clone());
371 }
372 }
373 }
374 None
375}
376
377fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
378 let mut out = Vec::new();
379 for m in mappings {
380 if !m.active {
381 continue;
382 }
383 let (service_type, routes) = m.effective_for(&m.service_target);
389 for route in routes {
390 if !input.matches_route(&route.input) {
391 continue;
392 }
393 if let Some(intent) = build_intent(route, input) {
394 out.push(RoutedIntent {
395 service_type: service_type.to_string(),
396 service_target: m.service_target.clone(),
397 intent,
398 });
399 break;
401 }
402 }
403 }
404 out
405}
406
407fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
408 let damping = route
409 .params
410 .get("damping")
411 .and_then(|v| v.as_f64())
412 .unwrap_or(1.0);
413
414 match route.intent.as_str() {
415 "play" => Some(Intent::Play),
416 "pause" => Some(Intent::Pause),
417 "play_pause" | "playpause" => Some(Intent::PlayPause),
418 "stop" => Some(Intent::Stop),
419 "next" => Some(Intent::Next),
420 "previous" => Some(Intent::Previous),
421 "mute" => Some(Intent::Mute),
422 "unmute" => Some(Intent::Unmute),
423 "power_toggle" => Some(Intent::PowerToggle),
424 "power_on" => Some(Intent::PowerOn),
425 "power_off" => Some(Intent::PowerOff),
426 "volume_change" => input
427 .continuous_value()
428 .map(|v| Intent::VolumeChange { delta: v * damping }),
429 "volume_set" => route
430 .params
431 .get("value")
432 .and_then(|v| v.as_f64())
433 .map(|value| Intent::VolumeSet { value }),
434 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
435 seconds: v * damping,
436 }),
437 "brightness_change" => input
438 .continuous_value()
439 .map(|v| Intent::BrightnessChange { delta: v * damping }),
440 "color_temperature_change" => input
441 .continuous_value()
442 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
443 _ => None,
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::Direction;
451 use std::collections::BTreeMap;
452 use uuid::Uuid;
453 use weave_contracts::Route;
454
455 fn rotate_mapping() -> Mapping {
456 Mapping {
457 mapping_id: Uuid::new_v4(),
458 edge_id: "living-room".into(),
459 device_type: "nuimo".into(),
460 device_id: "C3:81:DF:4E".into(),
461 service_type: "roon".into(),
462 service_target: "zone-1".into(),
463 routes: vec![Route {
464 input: "rotate".into(),
465 intent: "volume_change".into(),
466 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
467 }],
468 feedback: vec![],
469 active: true,
470 target_candidates: vec![],
471 target_switch_on: None,
472 }
473 }
474
475 #[tokio::test]
476 async fn rotate_produces_volume_change_with_damping() {
477 let engine = RoutingEngine::new();
478 engine.replace_all(vec![rotate_mapping()]).await;
479
480 let out = engine
481 .route(
482 "nuimo",
483 "C3:81:DF:4E",
484 &InputPrimitive::Rotate { delta: 0.03 },
485 )
486 .await;
487 assert_eq!(out.len(), 1);
488 match &out[0].intent {
489 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
490 other => panic!("expected VolumeChange, got {:?}", other),
491 }
492 assert_eq!(out[0].service_type, "roon");
493 assert_eq!(out[0].service_target, "zone-1");
494 }
495
496 #[tokio::test]
497 async fn inactive_mappings_are_skipped() {
498 let mut m = rotate_mapping();
499 m.active = false;
500 let engine = RoutingEngine::new();
501 engine.replace_all(vec![m]).await;
502
503 let out = engine
504 .route(
505 "nuimo",
506 "C3:81:DF:4E",
507 &InputPrimitive::Rotate { delta: 0.03 },
508 )
509 .await;
510 assert!(out.is_empty());
511 }
512
513 #[tokio::test]
514 async fn unknown_device_returns_empty() {
515 let engine = RoutingEngine::new();
516 engine.replace_all(vec![rotate_mapping()]).await;
517
518 let out = engine
519 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
520 .await;
521 assert!(out.is_empty());
522 }
523
524 fn selection_mapping() -> Mapping {
525 let mut m = rotate_mapping();
526 m.service_target = "target-A".into();
527 m.target_switch_on = Some("swipe_up".into());
528 m.target_candidates = vec![
529 TargetCandidate {
530 target: "target-A".into(),
531 label: "A".into(),
532 glyph: "glyph-a".into(),
533 service_type: None,
534 routes: None,
535 },
536 TargetCandidate {
537 target: "target-B".into(),
538 label: "B".into(),
539 glyph: "glyph-b".into(),
540 service_type: None,
541 routes: None,
542 },
543 ];
544 m
545 }
546
547 fn cross_service_mapping() -> Mapping {
551 let mut m = rotate_mapping();
552 m.service_target = "roon-zone".into();
553 m.target_switch_on = Some("long_press".into());
554 m.target_candidates = vec![
555 TargetCandidate {
556 target: "roon-zone".into(),
557 label: "Roon".into(),
558 glyph: "roon".into(),
559 service_type: None,
560 routes: None,
561 },
562 TargetCandidate {
563 target: "hue-light".into(),
564 label: "Hue".into(),
565 glyph: "hue".into(),
566 service_type: Some("hue".into()),
567 routes: Some(vec![Route {
568 input: "rotate".into(),
569 intent: "brightness_change".into(),
570 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
571 }]),
572 },
573 ];
574 m
575 }
576
577 #[test]
578 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
579 let m = cross_service_mapping();
580 let (svc, routes) = m.effective_for("roon-zone");
581 assert_eq!(svc, "roon");
582 assert_eq!(routes.len(), 1);
583 assert_eq!(routes[0].intent, "volume_change");
584 }
585
586 #[test]
587 fn effective_for_returns_candidate_overrides_when_present() {
588 let m = cross_service_mapping();
589 let (svc, routes) = m.effective_for("hue-light");
590 assert_eq!(svc, "hue");
591 assert_eq!(routes.len(), 1);
592 assert_eq!(routes[0].intent, "brightness_change");
593 }
594
595 #[tokio::test]
596 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
597 let mut m = cross_service_mapping();
598 m.service_target = "hue-light".into();
601
602 let engine = RoutingEngine::new();
603 engine.replace_all(vec![m]).await;
604
605 let out = engine
606 .route(
607 "nuimo",
608 "C3:81:DF:4E",
609 &InputPrimitive::Rotate { delta: 0.1 },
610 )
611 .await;
612 assert_eq!(out.len(), 1);
613 assert_eq!(out[0].service_type, "hue");
614 assert_eq!(out[0].service_target, "hue-light");
615 match &out[0].intent {
616 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
617 other => panic!("expected BrightnessChange, got {:?}", other),
618 }
619 }
620
621 #[tokio::test]
622 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
623 let engine = RoutingEngine::new();
624 engine.replace_all(vec![selection_mapping()]).await;
625
626 match engine
627 .route_with_mode(
628 "nuimo",
629 "C3:81:DF:4E",
630 &InputPrimitive::Swipe {
631 direction: Direction::Up,
632 },
633 )
634 .await
635 {
636 RouteOutcome::EnterSelection { glyph, .. } => {
637 assert_eq!(glyph, "glyph-b");
639 }
640 other => panic!("expected EnterSelection, got {:?}", other),
641 }
642
643 match engine
644 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
645 .await
646 {
647 RouteOutcome::CommitSelection { service_target, .. } => {
648 assert_eq!(service_target, "target-B")
649 }
650 other => panic!("expected CommitSelection, got {:?}", other),
651 }
652 }
653
654 #[tokio::test]
655 async fn rotate_then_press_commits_advanced_candidate() {
656 let engine = RoutingEngine::new();
657 engine.replace_all(vec![selection_mapping()]).await;
658
659 let _ = engine
661 .route_with_mode(
662 "nuimo",
663 "C3:81:DF:4E",
664 &InputPrimitive::Swipe {
665 direction: Direction::Up,
666 },
667 )
668 .await;
669 match engine
671 .route_with_mode(
672 "nuimo",
673 "C3:81:DF:4E",
674 &InputPrimitive::Rotate {
675 delta: SELECTION_ROTATION_STEP,
676 },
677 )
678 .await
679 {
680 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
681 other => panic!("expected UpdateSelection, got {:?}", other),
682 }
683 match engine
684 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
685 .await
686 {
687 RouteOutcome::CommitSelection { service_target, .. } => {
688 assert_eq!(service_target, "target-A")
689 }
690 other => panic!("expected CommitSelection, got {:?}", other),
691 }
692 }
693
694 #[tokio::test]
695 async fn sub_threshold_rotate_keeps_cursor_still() {
696 let engine = RoutingEngine::new();
697 engine.replace_all(vec![selection_mapping()]).await;
698
699 let _ = engine
701 .route_with_mode(
702 "nuimo",
703 "C3:81:DF:4E",
704 &InputPrimitive::Swipe {
705 direction: Direction::Up,
706 },
707 )
708 .await;
709
710 match engine
714 .route_with_mode(
715 "nuimo",
716 "C3:81:DF:4E",
717 &InputPrimitive::Rotate {
718 delta: SELECTION_ROTATION_STEP / 2.0,
719 },
720 )
721 .await
722 {
723 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
724 other => panic!("expected Normal(empty), got {:?}", other),
725 }
726
727 match engine
730 .route_with_mode(
731 "nuimo",
732 "C3:81:DF:4E",
733 &InputPrimitive::Rotate {
734 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
735 },
736 )
737 .await
738 {
739 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
740 other => panic!("expected UpdateSelection, got {:?}", other),
741 }
742 }
743
744 #[tokio::test]
745 async fn large_rotate_produces_single_step_per_outcome() {
746 let engine = RoutingEngine::new();
751 engine.replace_all(vec![selection_mapping()]).await;
752
753 let _ = engine
754 .route_with_mode(
755 "nuimo",
756 "C3:81:DF:4E",
757 &InputPrimitive::Swipe {
758 direction: Direction::Up,
759 },
760 )
761 .await;
762 match engine
766 .route_with_mode(
767 "nuimo",
768 "C3:81:DF:4E",
769 &InputPrimitive::Rotate {
770 delta: SELECTION_ROTATION_STEP * 2.0,
771 },
772 )
773 .await
774 {
775 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
776 other => panic!("expected UpdateSelection, got {:?}", other),
777 }
778 }
779
780 fn playback_glyph_rule() -> FeedbackRule {
781 FeedbackRule {
782 state: "playback".into(),
783 feedback_type: "glyph".into(),
784 mapping: serde_json::json!({
785 "playing": "play",
786 "paused": "pause",
787 "stopped": "pause",
788 }),
789 }
790 }
791
792 fn mapping_with_feedback() -> Mapping {
793 let mut m = rotate_mapping();
794 m.feedback = vec![playback_glyph_rule()];
795 m
796 }
797
798 #[tokio::test]
799 async fn feedback_rules_match_primary_target() {
800 let engine = RoutingEngine::new();
801 engine.replace_all(vec![mapping_with_feedback()]).await;
802
803 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
804 assert_eq!(rules.len(), 1);
805 assert_eq!(rules[0].state, "playback");
806 }
807
808 #[tokio::test]
809 async fn feedback_rules_match_candidate_override_service_type() {
810 let mut m = mapping_with_feedback();
811 m.target_candidates = vec![TargetCandidate {
812 target: "hue-light-1".into(),
813 label: "Living".into(),
814 glyph: "link".into(),
815 service_type: Some("hue".into()),
816 routes: None,
817 }];
818 let engine = RoutingEngine::new();
819 engine.replace_all(vec![m]).await;
820
821 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
822 assert_eq!(
823 rules.len(),
824 1,
825 "candidate with overridden service_type inherits mapping feedback",
826 );
827 }
828
829 #[tokio::test]
830 async fn feedback_rules_empty_for_unknown_target() {
831 let engine = RoutingEngine::new();
832 engine.replace_all(vec![mapping_with_feedback()]).await;
833
834 let rules = engine
835 .feedback_rules_for_target("roon", "some-other-zone")
836 .await;
837 assert!(rules.is_empty());
838 }
839
840 #[tokio::test]
841 async fn feedback_rules_for_device_target_scopes_by_device() {
842 let mut m_a = mapping_with_feedback();
845 m_a.device_id = "nuimo-a".into();
846 let mut m_b = mapping_with_feedback();
847 m_b.mapping_id = Uuid::new_v4();
848 m_b.device_id = "nuimo-b".into();
849 m_b.service_target = "zone-b".into();
850
851 let engine = RoutingEngine::new();
852 engine.replace_all(vec![m_a, m_b]).await;
853
854 let rules_a = engine
855 .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
856 .await;
857 let rules_a = rules_a.expect("nuimo-a owns zone-1");
858 assert_eq!(rules_a.len(), 1);
859
860 let rules_b_wrong = engine
861 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
862 .await;
863 assert!(
864 rules_b_wrong.is_none(),
865 "nuimo-b does not own zone-1 — must skip entirely",
866 );
867
868 let rules_b_right = engine
869 .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
870 .await;
871 let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
872 assert_eq!(rules_b_right.len(), 1);
873 }
874
875 #[tokio::test]
876 async fn feedback_rules_for_device_target_none_for_unknown_device() {
877 let engine = RoutingEngine::new();
878 engine.replace_all(vec![mapping_with_feedback()]).await;
879
880 let rules = engine
881 .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
882 .await;
883 assert!(
884 rules.is_none(),
885 "no mapping for unknown device — caller should skip",
886 );
887 }
888
889 #[tokio::test]
890 async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
891 let m = rotate_mapping(); let engine = RoutingEngine::new();
896 engine.replace_all(vec![m]).await;
897
898 let rules = engine
899 .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
900 .await;
901 let rules = rules.expect("mapping exists — caller should fall back to defaults");
902 assert!(
903 rules.is_empty(),
904 "no explicit rules configured — pump will use FeedbackPlan defaults",
905 );
906 }
907}