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 for m in list {
207 if m.service_type == service_type && m.service_target == target {
208 return m.feedback.clone();
209 }
210 for c in &m.target_candidates {
211 let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
212 if c_service_type == service_type && c.target == target {
213 return m.feedback.clone();
214 }
215 }
216 }
217 }
218 Vec::new()
219 }
220
221 pub async fn route(
224 &self,
225 device_type: &str,
226 device_id: &str,
227 input: &InputPrimitive,
228 ) -> Vec<RoutedIntent> {
229 let guard = self.by_device.read().await;
230 let Some(mappings) = guard.get(&(device_type.to_string(), device_id.to_string())) else {
231 return Vec::new();
232 };
233 route_mappings(mappings, input)
234 }
235
236 pub async fn route_with_mode(
241 &self,
242 device_type: &str,
243 device_id: &str,
244 input: &InputPrimitive,
245 ) -> RouteOutcome {
246 let key = (device_type.to_string(), device_id.to_string());
247
248 {
251 let mut sel = self.selection.write().await;
252 if let Some(mode) = sel.get_mut(&key) {
253 match input {
254 InputPrimitive::Rotate { delta } => {
255 if !mode.advance(*delta) {
260 return RouteOutcome::Normal(Vec::new());
261 }
262 let glyph = mode.current().glyph.clone();
263 let mapping_id = mode.mapping_id;
264 return RouteOutcome::UpdateSelection { mapping_id, glyph };
265 }
266 InputPrimitive::Press => {
267 let mapping_id = mode.mapping_id;
268 let service_target = mode.current().target.clone();
269 let edge_id = mode.edge_id.clone();
270 sel.remove(&key);
271 return RouteOutcome::CommitSelection {
272 edge_id,
273 mapping_id,
274 service_target,
275 };
276 }
277 _ => {
278 let mapping_id = mode.mapping_id;
279 sel.remove(&key);
280 return RouteOutcome::CancelSelection { mapping_id };
281 }
282 }
283 }
284 }
285
286 let guard = self.by_device.read().await;
289 let Some(mappings) = guard.get(&key) else {
290 return RouteOutcome::Normal(Vec::new());
291 };
292
293 for m in mappings {
294 if !m.active {
295 continue;
296 }
297 let Some(switch_on) = m.target_switch_on.as_deref() else {
298 continue;
299 };
300 if m.target_candidates.is_empty() {
301 continue;
302 }
303 if !input.matches_route(switch_on) {
304 continue;
305 }
306 let current_idx = m
310 .target_candidates
311 .iter()
312 .position(|c| c.target == m.service_target);
313 let cursor = match current_idx {
314 Some(i) => (i + 1) % m.target_candidates.len(),
315 None => 0,
316 };
317 let mode = SelectionMode::new(
318 m.mapping_id,
319 m.edge_id.clone(),
320 m.target_candidates.clone(),
321 cursor,
322 );
323 let glyph = mode.current().glyph.clone();
324 let mapping_id = mode.mapping_id;
325 let edge_id = mode.edge_id.clone();
326 drop(guard);
327 self.selection.write().await.insert(key, mode);
328 return RouteOutcome::EnterSelection {
329 edge_id,
330 mapping_id,
331 glyph,
332 };
333 }
334
335 RouteOutcome::Normal(route_mappings(mappings, input))
336 }
337}
338
339fn route_mappings(mappings: &[Mapping], input: &InputPrimitive) -> Vec<RoutedIntent> {
340 let mut out = Vec::new();
341 for m in mappings {
342 if !m.active {
343 continue;
344 }
345 let (service_type, routes) = m.effective_for(&m.service_target);
351 for route in routes {
352 if !input.matches_route(&route.input) {
353 continue;
354 }
355 if let Some(intent) = build_intent(route, input) {
356 out.push(RoutedIntent {
357 service_type: service_type.to_string(),
358 service_target: m.service_target.clone(),
359 intent,
360 });
361 break;
363 }
364 }
365 }
366 out
367}
368
369fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
370 let damping = route
371 .params
372 .get("damping")
373 .and_then(|v| v.as_f64())
374 .unwrap_or(1.0);
375
376 match route.intent.as_str() {
377 "play" => Some(Intent::Play),
378 "pause" => Some(Intent::Pause),
379 "play_pause" | "playpause" => Some(Intent::PlayPause),
380 "stop" => Some(Intent::Stop),
381 "next" => Some(Intent::Next),
382 "previous" => Some(Intent::Previous),
383 "mute" => Some(Intent::Mute),
384 "unmute" => Some(Intent::Unmute),
385 "power_toggle" => Some(Intent::PowerToggle),
386 "power_on" => Some(Intent::PowerOn),
387 "power_off" => Some(Intent::PowerOff),
388 "volume_change" => input
389 .continuous_value()
390 .map(|v| Intent::VolumeChange { delta: v * damping }),
391 "volume_set" => route
392 .params
393 .get("value")
394 .and_then(|v| v.as_f64())
395 .map(|value| Intent::VolumeSet { value }),
396 "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
397 seconds: v * damping,
398 }),
399 "brightness_change" => input
400 .continuous_value()
401 .map(|v| Intent::BrightnessChange { delta: v * damping }),
402 "color_temperature_change" => input
403 .continuous_value()
404 .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
405 _ => None,
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::Direction;
413 use std::collections::BTreeMap;
414 use uuid::Uuid;
415 use weave_contracts::Route;
416
417 fn rotate_mapping() -> Mapping {
418 Mapping {
419 mapping_id: Uuid::new_v4(),
420 edge_id: "living-room".into(),
421 device_type: "nuimo".into(),
422 device_id: "C3:81:DF:4E".into(),
423 service_type: "roon".into(),
424 service_target: "zone-1".into(),
425 routes: vec![Route {
426 input: "rotate".into(),
427 intent: "volume_change".into(),
428 params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
429 }],
430 feedback: vec![],
431 active: true,
432 target_candidates: vec![],
433 target_switch_on: None,
434 }
435 }
436
437 #[tokio::test]
438 async fn rotate_produces_volume_change_with_damping() {
439 let engine = RoutingEngine::new();
440 engine.replace_all(vec![rotate_mapping()]).await;
441
442 let out = engine
443 .route(
444 "nuimo",
445 "C3:81:DF:4E",
446 &InputPrimitive::Rotate { delta: 0.03 },
447 )
448 .await;
449 assert_eq!(out.len(), 1);
450 match &out[0].intent {
451 Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
452 other => panic!("expected VolumeChange, got {:?}", other),
453 }
454 assert_eq!(out[0].service_type, "roon");
455 assert_eq!(out[0].service_target, "zone-1");
456 }
457
458 #[tokio::test]
459 async fn inactive_mappings_are_skipped() {
460 let mut m = rotate_mapping();
461 m.active = false;
462 let engine = RoutingEngine::new();
463 engine.replace_all(vec![m]).await;
464
465 let out = engine
466 .route(
467 "nuimo",
468 "C3:81:DF:4E",
469 &InputPrimitive::Rotate { delta: 0.03 },
470 )
471 .await;
472 assert!(out.is_empty());
473 }
474
475 #[tokio::test]
476 async fn unknown_device_returns_empty() {
477 let engine = RoutingEngine::new();
478 engine.replace_all(vec![rotate_mapping()]).await;
479
480 let out = engine
481 .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
482 .await;
483 assert!(out.is_empty());
484 }
485
486 fn selection_mapping() -> Mapping {
487 let mut m = rotate_mapping();
488 m.service_target = "target-A".into();
489 m.target_switch_on = Some("swipe_up".into());
490 m.target_candidates = vec![
491 TargetCandidate {
492 target: "target-A".into(),
493 label: "A".into(),
494 glyph: "glyph-a".into(),
495 service_type: None,
496 routes: None,
497 },
498 TargetCandidate {
499 target: "target-B".into(),
500 label: "B".into(),
501 glyph: "glyph-b".into(),
502 service_type: None,
503 routes: None,
504 },
505 ];
506 m
507 }
508
509 fn cross_service_mapping() -> Mapping {
513 let mut m = rotate_mapping();
514 m.service_target = "roon-zone".into();
515 m.target_switch_on = Some("long_press".into());
516 m.target_candidates = vec![
517 TargetCandidate {
518 target: "roon-zone".into(),
519 label: "Roon".into(),
520 glyph: "roon".into(),
521 service_type: None,
522 routes: None,
523 },
524 TargetCandidate {
525 target: "hue-light".into(),
526 label: "Hue".into(),
527 glyph: "hue".into(),
528 service_type: Some("hue".into()),
529 routes: Some(vec![Route {
530 input: "rotate".into(),
531 intent: "brightness_change".into(),
532 params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
533 }]),
534 },
535 ];
536 m
537 }
538
539 #[test]
540 fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
541 let m = cross_service_mapping();
542 let (svc, routes) = m.effective_for("roon-zone");
543 assert_eq!(svc, "roon");
544 assert_eq!(routes.len(), 1);
545 assert_eq!(routes[0].intent, "volume_change");
546 }
547
548 #[test]
549 fn effective_for_returns_candidate_overrides_when_present() {
550 let m = cross_service_mapping();
551 let (svc, routes) = m.effective_for("hue-light");
552 assert_eq!(svc, "hue");
553 assert_eq!(routes.len(), 1);
554 assert_eq!(routes[0].intent, "brightness_change");
555 }
556
557 #[tokio::test]
558 async fn rotate_on_cross_service_candidate_produces_hue_intent() {
559 let mut m = cross_service_mapping();
560 m.service_target = "hue-light".into();
563
564 let engine = RoutingEngine::new();
565 engine.replace_all(vec![m]).await;
566
567 let out = engine
568 .route(
569 "nuimo",
570 "C3:81:DF:4E",
571 &InputPrimitive::Rotate { delta: 0.1 },
572 )
573 .await;
574 assert_eq!(out.len(), 1);
575 assert_eq!(out[0].service_type, "hue");
576 assert_eq!(out[0].service_target, "hue-light");
577 match &out[0].intent {
578 Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
579 other => panic!("expected BrightnessChange, got {:?}", other),
580 }
581 }
582
583 #[tokio::test]
584 async fn swipe_up_press_cycles_to_next_target_without_rotate() {
585 let engine = RoutingEngine::new();
586 engine.replace_all(vec![selection_mapping()]).await;
587
588 match engine
589 .route_with_mode(
590 "nuimo",
591 "C3:81:DF:4E",
592 &InputPrimitive::Swipe {
593 direction: Direction::Up,
594 },
595 )
596 .await
597 {
598 RouteOutcome::EnterSelection { glyph, .. } => {
599 assert_eq!(glyph, "glyph-b");
601 }
602 other => panic!("expected EnterSelection, got {:?}", other),
603 }
604
605 match engine
606 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
607 .await
608 {
609 RouteOutcome::CommitSelection { service_target, .. } => {
610 assert_eq!(service_target, "target-B")
611 }
612 other => panic!("expected CommitSelection, got {:?}", other),
613 }
614 }
615
616 #[tokio::test]
617 async fn rotate_then_press_commits_advanced_candidate() {
618 let engine = RoutingEngine::new();
619 engine.replace_all(vec![selection_mapping()]).await;
620
621 let _ = engine
623 .route_with_mode(
624 "nuimo",
625 "C3:81:DF:4E",
626 &InputPrimitive::Swipe {
627 direction: Direction::Up,
628 },
629 )
630 .await;
631 match engine
633 .route_with_mode(
634 "nuimo",
635 "C3:81:DF:4E",
636 &InputPrimitive::Rotate {
637 delta: SELECTION_ROTATION_STEP,
638 },
639 )
640 .await
641 {
642 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
643 other => panic!("expected UpdateSelection, got {:?}", other),
644 }
645 match engine
646 .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
647 .await
648 {
649 RouteOutcome::CommitSelection { service_target, .. } => {
650 assert_eq!(service_target, "target-A")
651 }
652 other => panic!("expected CommitSelection, got {:?}", other),
653 }
654 }
655
656 #[tokio::test]
657 async fn sub_threshold_rotate_keeps_cursor_still() {
658 let engine = RoutingEngine::new();
659 engine.replace_all(vec![selection_mapping()]).await;
660
661 let _ = engine
663 .route_with_mode(
664 "nuimo",
665 "C3:81:DF:4E",
666 &InputPrimitive::Swipe {
667 direction: Direction::Up,
668 },
669 )
670 .await;
671
672 match engine
676 .route_with_mode(
677 "nuimo",
678 "C3:81:DF:4E",
679 &InputPrimitive::Rotate {
680 delta: SELECTION_ROTATION_STEP / 2.0,
681 },
682 )
683 .await
684 {
685 RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
686 other => panic!("expected Normal(empty), got {:?}", other),
687 }
688
689 match engine
692 .route_with_mode(
693 "nuimo",
694 "C3:81:DF:4E",
695 &InputPrimitive::Rotate {
696 delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
697 },
698 )
699 .await
700 {
701 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
702 other => panic!("expected UpdateSelection, got {:?}", other),
703 }
704 }
705
706 #[tokio::test]
707 async fn large_rotate_produces_single_step_per_outcome() {
708 let engine = RoutingEngine::new();
713 engine.replace_all(vec![selection_mapping()]).await;
714
715 let _ = engine
716 .route_with_mode(
717 "nuimo",
718 "C3:81:DF:4E",
719 &InputPrimitive::Swipe {
720 direction: Direction::Up,
721 },
722 )
723 .await;
724 match engine
728 .route_with_mode(
729 "nuimo",
730 "C3:81:DF:4E",
731 &InputPrimitive::Rotate {
732 delta: SELECTION_ROTATION_STEP * 2.0,
733 },
734 )
735 .await
736 {
737 RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
738 other => panic!("expected UpdateSelection, got {:?}", other),
739 }
740 }
741
742 fn playback_glyph_rule() -> FeedbackRule {
743 FeedbackRule {
744 state: "playback".into(),
745 feedback_type: "glyph".into(),
746 mapping: serde_json::json!({
747 "playing": "play",
748 "paused": "pause",
749 "stopped": "pause",
750 }),
751 }
752 }
753
754 fn mapping_with_feedback() -> Mapping {
755 let mut m = rotate_mapping();
756 m.feedback = vec![playback_glyph_rule()];
757 m
758 }
759
760 #[tokio::test]
761 async fn feedback_rules_match_primary_target() {
762 let engine = RoutingEngine::new();
763 engine.replace_all(vec![mapping_with_feedback()]).await;
764
765 let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
766 assert_eq!(rules.len(), 1);
767 assert_eq!(rules[0].state, "playback");
768 }
769
770 #[tokio::test]
771 async fn feedback_rules_match_candidate_override_service_type() {
772 let mut m = mapping_with_feedback();
773 m.target_candidates = vec![TargetCandidate {
774 target: "hue-light-1".into(),
775 label: "Living".into(),
776 glyph: "link".into(),
777 service_type: Some("hue".into()),
778 routes: None,
779 }];
780 let engine = RoutingEngine::new();
781 engine.replace_all(vec![m]).await;
782
783 let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
784 assert_eq!(
785 rules.len(),
786 1,
787 "candidate with overridden service_type inherits mapping feedback",
788 );
789 }
790
791 #[tokio::test]
792 async fn feedback_rules_empty_for_unknown_target() {
793 let engine = RoutingEngine::new();
794 engine.replace_all(vec![mapping_with_feedback()]).await;
795
796 let rules = engine
797 .feedback_rules_for_target("roon", "some-other-zone")
798 .await;
799 assert!(rules.is_empty());
800 }
801}