Skip to main content

edge_core/
routing.rs

1//! Routing engine: applies a device input primitive against the active
2//! mappings to produce zero or more `(service_type, target, intent)` triples.
3
4use std::collections::HashMap;
5
6use tokio::sync::RwLock;
7use uuid::Uuid;
8use weave_contracts::{DeviceCycle, FeedbackRule, Mapping, Route, TargetCandidate};
9
10use super::intent::{InputPrimitive, Intent};
11
12/// A concrete intent destined for a specific service target.
13#[derive(Debug, Clone)]
14pub struct RoutedIntent {
15    pub service_type: String,
16    pub service_target: String,
17    pub intent: Intent,
18}
19
20/// Per-(device_type, device_id) transient state used by target-selection
21/// mode. Keyed off the mapping that declared `target_switch_on`. While
22/// this struct is in `RoutingEngine::selection`, the device has entered
23/// mode: `Rotate` browses `cursor`, `Press` commits, any other input
24/// cancels.
25#[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    /// Accumulated rotation delta since the last cursor step. Nuimo emits
32    /// rotation events at the characteristic update rate — each encoder
33    /// tick is ~1/2650 of a full turn — so a "one tick = one candidate"
34    /// policy skips through the whole list on a light nudge. The
35    /// accumulator turns that into "one candidate per `SELECTION_ROTATION_STEP`
36    /// radians-fraction of travel" so the selection ramp is legible at
37    /// hardware rotation speeds.
38    rotation_accumulator: f64,
39}
40
41/// Fraction of a full Nuimo turn required to advance the selection
42/// cursor by one candidate. `1.0` = one full 360° turn; `0.25` = a
43/// quarter turn. Tuned experimentally: for the typical 2-4 candidate
44/// case this gives roughly one candidate per thumb-flick, versus the
45/// pre-damping behaviour where a finger slip cycled through every
46/// candidate in the list.
47pub 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    /// Add `delta` to the rotation accumulator and advance the cursor by
70    /// one candidate for each full `SELECTION_ROTATION_STEP` worth of
71    /// accumulated travel. Returns `true` if the cursor moved — callers
72    /// use this to suppress redundant LED redraws for sub-threshold
73    /// rotations.
74    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/// One outcome of routing a single input primitive. `Normal` is the
96/// existing "route to zero or more intents" path; the other variants are
97/// target-selection side effects that the caller must translate into LED
98/// feedback or a server-bound `SwitchTarget` frame.
99#[derive(Debug, Clone)]
100pub enum RouteOutcome {
101    Normal(Vec<RoutedIntent>),
102    /// Device just entered selection mode; show `glyph` on the LED.
103    EnterSelection {
104        edge_id: String,
105        mapping_id: Uuid,
106        glyph: String,
107    },
108    /// Device still in selection mode, cursor moved; show `glyph`.
109    UpdateSelection {
110        mapping_id: Uuid,
111        glyph: String,
112    },
113    /// Device committed the selection; caller sends
114    /// `EdgeToServer::SwitchTarget` and optionally clears the LED.
115    CommitSelection {
116        edge_id: String,
117        mapping_id: Uuid,
118        service_target: String,
119    },
120    /// Device exited selection mode without committing (non-rotate/press
121    /// input). Caller should clear any lingering LED feedback.
122    CancelSelection {
123        mapping_id: Uuid,
124    },
125    /// Cycle gesture matched on a device with a DeviceCycle. Caller updates
126    /// active locally (the engine has already done so) and sends
127    /// `EdgeToServer::SwitchActiveConnection` so the server can persist
128    /// and broadcast to peer edges + the web UI.
129    CycleSwitch {
130        device_type: String,
131        device_id: String,
132        active_mapping_id: Uuid,
133    },
134}
135
136/// Thread-safe registry of currently-active mappings, keyed by
137/// `(device_type, device_id)` for O(1) lookup per input event.
138#[derive(Default)]
139pub struct RoutingEngine {
140    by_device: RwLock<HashMap<(String, String), Vec<Mapping>>>,
141    selection: RwLock<HashMap<(String, String), SelectionMode>>,
142    /// Device-level cycles. When present for `(device_type, device_id)`,
143    /// `route()` filters mappings to the cycle's `active_mapping_id`
144    /// (other cycle members + non-cycle mappings on that device sit
145    /// dormant), and inputs matching `cycle_gesture` short-circuit to a
146    /// `CycleSwitch` outcome that advances active locally and asks the
147    /// caller to inform the server.
148    cycles: RwLock<HashMap<(String, String), DeviceCycle>>,
149}
150
151impl RoutingEngine {
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Replace all mappings with the provided set. Used on `config_full` push.
157    pub async fn replace_all(&self, mappings: Vec<Mapping>) {
158        let mut by_device: HashMap<(String, String), Vec<Mapping>> = HashMap::new();
159        for m in mappings {
160            by_device
161                .entry((m.device_type.clone(), m.device_id.clone()))
162                .or_default()
163                .push(m);
164        }
165        *self.by_device.write().await = by_device;
166    }
167
168    /// Insert or replace one mapping, keyed by `mapping_id`. Used on
169    /// `config_patch` upsert.
170    pub async fn upsert_mapping(&self, mapping: Mapping) {
171        let mut guard = self.by_device.write().await;
172        // Remove any prior entry with the same mapping_id, regardless of
173        // device key (handles device reassignment).
174        for list in guard.values_mut() {
175            list.retain(|m| m.mapping_id != mapping.mapping_id);
176        }
177        guard
178            .entry((mapping.device_type.clone(), mapping.device_id.clone()))
179            .or_default()
180            .push(mapping);
181    }
182
183    /// Remove any mapping with the given `mapping_id`. Used on
184    /// `config_patch` delete.
185    pub async fn remove_mapping(&self, id: &uuid::Uuid) {
186        let mut guard = self.by_device.write().await;
187        for list in guard.values_mut() {
188            list.retain(|m| &m.mapping_id != id);
189        }
190        // Prune empty device buckets so route() stays tight.
191        guard.retain(|_, list| !list.is_empty());
192    }
193
194    /// Snapshot every mapping currently held, grouped or not. Used to
195    /// persist the local cache after an incremental patch.
196    pub async fn snapshot(&self) -> Vec<Mapping> {
197        self.by_device
198            .read()
199            .await
200            .values()
201            .flatten()
202            .cloned()
203            .collect()
204    }
205
206    /// Replace all device cycles. Used on `config_full` push.
207    pub async fn replace_cycles(&self, cycles: Vec<DeviceCycle>) {
208        let mut map: HashMap<(String, String), DeviceCycle> = HashMap::new();
209        for c in cycles {
210            map.insert((c.device_type.clone(), c.device_id.clone()), c);
211        }
212        *self.cycles.write().await = map;
213    }
214
215    /// Insert or replace a device cycle. Used on `device_cycle_patch` upsert.
216    pub async fn upsert_cycle(&self, cycle: DeviceCycle) {
217        let key = (cycle.device_type.clone(), cycle.device_id.clone());
218        self.cycles.write().await.insert(key, cycle);
219    }
220
221    /// Remove a device's cycle. Used on `device_cycle_patch` delete.
222    pub async fn remove_cycle(&self, device_type: &str, device_id: &str) -> bool {
223        let key = (device_type.to_string(), device_id.to_string());
224        self.cycles.write().await.remove(&key).is_some()
225    }
226
227    /// Update only the active mapping ID for an existing cycle. Returns
228    /// false if no cycle exists or `active_mapping_id` is not in the
229    /// cycle's `mapping_ids` list.
230    pub async fn set_cycle_active(
231        &self,
232        device_type: &str,
233        device_id: &str,
234        active_mapping_id: Uuid,
235    ) -> bool {
236        let key = (device_type.to_string(), device_id.to_string());
237        let mut cycles = self.cycles.write().await;
238        let Some(cycle) = cycles.get_mut(&key) else {
239            return false;
240        };
241        if !cycle.mapping_ids.contains(&active_mapping_id) {
242            return false;
243        }
244        cycle.active_mapping_id = Some(active_mapping_id);
245        true
246    }
247
248    /// Snapshot every cycle. Used to persist the local cache.
249    pub async fn cycles_snapshot(&self) -> Vec<DeviceCycle> {
250        self.cycles.read().await.values().cloned().collect()
251    }
252
253    /// Fetch a cycle by device key, if any.
254    pub async fn cycle_for(&self, device_type: &str, device_id: &str) -> Option<DeviceCycle> {
255        let key = (device_type.to_string(), device_id.to_string());
256        self.cycles.read().await.get(&key).cloned()
257    }
258
259    /// Feedback rules attached to whichever mapping covers the given
260    /// `(service_type, target)` — either as its primary target or as a
261    /// listed candidate (including candidates that override the mapping's
262    /// `service_type`). Returns an empty vec when no mapping covers the
263    /// target, letting the caller fall back to hardcoded defaults.
264    ///
265    /// Feedback is stored at the mapping level (all candidates share one
266    /// rule set), so a candidate match still returns the mapping's
267    /// `feedback`.
268    pub async fn feedback_rules_for_target(
269        &self,
270        service_type: &str,
271        target: &str,
272    ) -> Vec<FeedbackRule> {
273        let guard = self.by_device.read().await;
274        for list in guard.values() {
275            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
276                return rules;
277            }
278        }
279        Vec::new()
280    }
281
282    /// Same as `feedback_rules_for_target` but scoped to one `(device_type,
283    /// device_id)` bucket. Use this from per-device feedback pumps so a
284    /// Nuimo only reacts when the state change belongs to a mapping it
285    /// owns — otherwise a second Nuimo mapped elsewhere would flash glyphs
286    /// for unrelated service activity.
287    ///
288    /// Returns `None` when no mapping on this device covers
289    /// `(service_type, target)` — the caller should skip. Returns
290    /// `Some(vec)` when a mapping exists; the vec may be empty, which
291    /// signals "mapping exists but user configured no rules — fall back
292    /// to hardcoded defaults" (preserves single-device behavior).
293    pub async fn feedback_rules_for_device_target(
294        &self,
295        device_type: &str,
296        device_id: &str,
297        service_type: &str,
298        target: &str,
299    ) -> Option<Vec<FeedbackRule>> {
300        let guard = self.by_device.read().await;
301        let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
302        mapping_rules_for_target(list, service_type, target)
303    }
304
305    /// Find every device whose mapping owns `(service_type, target)` and
306    /// return its `(device_type, device_id, feedback_rules)`. Used by the
307    /// iOS feedback pump, which fans a single state update out to every
308    /// LED that should display it — typically 1 device, but two Nuimos
309    /// paired to the same iPad both deserve feedback.
310    pub async fn feedback_targets_for(
311        &self,
312        service_type: &str,
313        target: &str,
314    ) -> Vec<(String, String, Vec<FeedbackRule>)> {
315        let guard = self.by_device.read().await;
316        let mut out = Vec::new();
317        for ((device_type, device_id), list) in guard.iter() {
318            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
319                out.push((device_type.clone(), device_id.clone(), rules));
320            }
321        }
322        out
323    }
324
325    /// Apply the given input primitive from a specific device, returning every
326    /// intent it produces across all matching mappings.
327    ///
328    /// When the device has a `DeviceCycle`, only the mapping identified
329    /// by `active_mapping_id` is eligible — non-active members and
330    /// non-member mappings on the same device sit dormant. Cycle-gesture
331    /// inputs are NOT short-circuited here (they would silently dispatch
332    /// nothing); use `route_with_mode` to surface a `CycleSwitch` outcome.
333    pub async fn route(
334        &self,
335        device_type: &str,
336        device_id: &str,
337        input: &InputPrimitive,
338    ) -> Vec<RoutedIntent> {
339        let key = (device_type.to_string(), device_id.to_string());
340        let cycle = self.cycles.read().await.get(&key).cloned();
341        let guard = self.by_device.read().await;
342        let Some(mappings) = guard.get(&key) else {
343            return Vec::new();
344        };
345        let active_filter = active_filter_from_cycle(cycle.as_ref());
346        route_mappings(mappings, input, active_filter)
347    }
348
349    /// Same dispatch as `route`, but also implements:
350    /// - the device-level Cycle: cycle-gesture inputs short-circuit to a
351    ///   `CycleSwitch` outcome (active is advanced locally; caller relays
352    ///   to server). When a cycle exists, only the active mapping fires.
353    /// - the legacy `target_switch_on` + `target_candidates` selection
354    ///   mode for backward compat — only consulted when no cycle exists
355    ///   for the device.
356    pub async fn route_with_mode(
357        &self,
358        device_type: &str,
359        device_id: &str,
360        input: &InputPrimitive,
361    ) -> RouteOutcome {
362        let key = (device_type.to_string(), device_id.to_string());
363
364        // Cycle path: takes precedence over legacy selection mode.
365        // If the input matches `cycle_gesture`, advance active locally
366        // and return a CycleSwitch outcome. Otherwise apply the active
367        // filter to mapping routing.
368        {
369            let cycles = self.cycles.read().await;
370            if let Some(cycle) = cycles.get(&key).cloned() {
371                drop(cycles);
372                if let Some(gesture) = cycle.cycle_gesture.as_deref() {
373                    if input.matches_route(gesture) {
374                        // Compute next active without holding the read
375                        // lock through the write.
376                        let next = next_active(&cycle);
377                        if let Some(next_id) = next {
378                            // Advance local state; engine is the source
379                            // of truth until server confirms.
380                            self.cycles
381                                .write()
382                                .await
383                                .entry(key.clone())
384                                .and_modify(|c| {
385                                    c.active_mapping_id = Some(next_id);
386                                });
387                            return RouteOutcome::CycleSwitch {
388                                device_type: device_type.to_string(),
389                                device_id: device_id.to_string(),
390                                active_mapping_id: next_id,
391                            };
392                        }
393                    }
394                }
395                // Non-cycle-gesture input on a cycled device: filter
396                // to the active mapping and route normally. Skip the
397                // legacy selection-mode path entirely — devices on
398                // the cycle model don't enter selection mode.
399                let active_filter = active_filter_from_cycle(Some(&cycle));
400                let guard = self.by_device.read().await;
401                let Some(mappings) = guard.get(&key) else {
402                    return RouteOutcome::Normal(Vec::new());
403                };
404                return RouteOutcome::Normal(route_mappings(mappings, input, active_filter));
405            }
406        }
407
408        // Already in selection mode for this device: intercept rotate /
409        // press / cancel before dispatching to normal routing.
410        {
411            let mut sel = self.selection.write().await;
412            if let Some(mode) = sel.get_mut(&key) {
413                match input {
414                    InputPrimitive::Rotate { delta } => {
415                        // Sub-threshold rotations accumulate without
416                        // emitting UpdateSelection — the LED keeps the
417                        // current candidate's glyph and no log line
418                        // fires until the cursor actually moves.
419                        if !mode.advance(*delta) {
420                            return RouteOutcome::Normal(Vec::new());
421                        }
422                        let glyph = mode.current().glyph.clone();
423                        let mapping_id = mode.mapping_id;
424                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
425                    }
426                    InputPrimitive::Press => {
427                        let mapping_id = mode.mapping_id;
428                        let service_target = mode.current().target.clone();
429                        let edge_id = mode.edge_id.clone();
430                        sel.remove(&key);
431                        return RouteOutcome::CommitSelection {
432                            edge_id,
433                            mapping_id,
434                            service_target,
435                        };
436                    }
437                    _ => {
438                        let mapping_id = mode.mapping_id;
439                        sel.remove(&key);
440                        return RouteOutcome::CancelSelection { mapping_id };
441                    }
442                }
443            }
444        }
445
446        // Not in mode yet: check whether this input should enter mode on
447        // any mapping for this device, else fall through to normal routing.
448        let guard = self.by_device.read().await;
449        let Some(mappings) = guard.get(&key) else {
450            return RouteOutcome::Normal(Vec::new());
451        };
452
453        for m in mappings {
454            if !m.active {
455                continue;
456            }
457            let Some(switch_on) = m.target_switch_on.as_deref() else {
458                continue;
459            };
460            if m.target_candidates.is_empty() {
461                continue;
462            }
463            if !input.matches_route(switch_on) {
464                continue;
465            }
466            // Enter mode with cursor one step AFTER the current
467            // service_target — so swipe_up→press (no rotate) cycles to
468            // the next candidate. Rotate still browses from there.
469            let current_idx = m
470                .target_candidates
471                .iter()
472                .position(|c| c.target == m.service_target);
473            let cursor = match current_idx {
474                Some(i) => (i + 1) % m.target_candidates.len(),
475                None => 0,
476            };
477            let mode = SelectionMode::new(
478                m.mapping_id,
479                m.edge_id.clone(),
480                m.target_candidates.clone(),
481                cursor,
482            );
483            let glyph = mode.current().glyph.clone();
484            let mapping_id = mode.mapping_id;
485            let edge_id = mode.edge_id.clone();
486            drop(guard);
487            self.selection.write().await.insert(key, mode);
488            return RouteOutcome::EnterSelection {
489                edge_id,
490                mapping_id,
491                glyph,
492            };
493        }
494
495        RouteOutcome::Normal(route_mappings(mappings, input, None))
496    }
497}
498
499/// Compute the next active mapping ID in the cycle order. Returns `None`
500/// when the cycle is empty or its current `active_mapping_id` does not
501/// match any entry (caller may want to reset to head, but this helper
502/// stays conservative).
503fn next_active(cycle: &DeviceCycle) -> Option<Uuid> {
504    if cycle.mapping_ids.is_empty() {
505        return None;
506    }
507    let active = cycle.active_mapping_id?;
508    let pos = cycle.mapping_ids.iter().position(|id| *id == active)?;
509    let next = (pos + 1) % cycle.mapping_ids.len();
510    Some(cycle.mapping_ids[next])
511}
512
513/// Extract the active filter UUID from a cycle if one exists. `None`
514/// means "no cycle — all matching mappings fire" (preserves backward
515/// compatibility for devices without a cycle).
516fn active_filter_from_cycle(cycle: Option<&DeviceCycle>) -> Option<Uuid> {
517    cycle.and_then(|c| c.active_mapping_id)
518}
519
520/// Look for a mapping in `list` whose primary `(service_type, target)` or
521/// any `target_candidates` entry matches the given pair. Returns the
522/// mapping's `feedback` clone on hit. `None` means no mapping covers the
523/// target, letting the caller fall back to defaults.
524fn mapping_rules_for_target(
525    list: &[Mapping],
526    service_type: &str,
527    target: &str,
528) -> Option<Vec<FeedbackRule>> {
529    for m in list {
530        if m.service_type == service_type && m.service_target == target {
531            return Some(m.feedback.clone());
532        }
533        for c in &m.target_candidates {
534            let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
535            if c_service_type == service_type && c.target == target {
536                return Some(m.feedback.clone());
537            }
538        }
539    }
540    None
541}
542
543fn route_mappings(
544    mappings: &[Mapping],
545    input: &InputPrimitive,
546    active_filter: Option<Uuid>,
547) -> Vec<RoutedIntent> {
548    let mut out = Vec::new();
549    for m in mappings {
550        if !m.active {
551            continue;
552        }
553        // When a cycle is in effect, only the active mapping fires.
554        // Other cycle members + non-cycle mappings on this device are
555        // skipped. (The cycle's mapping_ids list is enforced on the
556        // active_filter side — only the active id will pass through.)
557        if let Some(active_id) = active_filter {
558            if m.mapping_id != active_id {
559                continue;
560            }
561        }
562        // Resolve the effective service_type + routes for the currently
563        // active target. When the active target is a cross-service
564        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
565        // `effective_for` returns the candidate's overrides so the
566        // dispatcher sees the right adapter + intent set.
567        let (service_type, routes) = m.effective_for(&m.service_target);
568        for route in routes {
569            if !input.matches_route(&route.input) {
570                continue;
571            }
572            if let Some(intent) = build_intent(route, input) {
573                out.push(RoutedIntent {
574                    service_type: service_type.to_string(),
575                    service_target: m.service_target.clone(),
576                    intent,
577                });
578                // Only first matching route per mapping fires.
579                break;
580            }
581        }
582    }
583    out
584}
585
586fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
587    let damping = route
588        .params
589        .get("damping")
590        .and_then(|v| v.as_f64())
591        .unwrap_or(1.0);
592
593    match route.intent.as_str() {
594        "play" => Some(Intent::Play),
595        "pause" => Some(Intent::Pause),
596        "play_pause" | "playpause" => Some(Intent::PlayPause),
597        "stop" => Some(Intent::Stop),
598        "next" => Some(Intent::Next),
599        "previous" => Some(Intent::Previous),
600        "mute" => Some(Intent::Mute),
601        "unmute" => Some(Intent::Unmute),
602        "power_toggle" => Some(Intent::PowerToggle),
603        "power_on" => Some(Intent::PowerOn),
604        "power_off" => Some(Intent::PowerOff),
605        "volume_change" => input
606            .continuous_value()
607            .map(|v| Intent::VolumeChange { delta: v * damping }),
608        "volume_set" => route
609            .params
610            .get("value")
611            .and_then(|v| v.as_f64())
612            .map(|value| Intent::VolumeSet { value }),
613        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
614            seconds: v * damping,
615        }),
616        "brightness_change" => input
617            .continuous_value()
618            .map(|v| Intent::BrightnessChange { delta: v * damping }),
619        "color_temperature_change" => input
620            .continuous_value()
621            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
622        _ => None,
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use crate::Direction;
630    use std::collections::BTreeMap;
631    use uuid::Uuid;
632    use weave_contracts::Route;
633
634    fn rotate_mapping() -> Mapping {
635        Mapping {
636            mapping_id: Uuid::new_v4(),
637            edge_id: "living-room".into(),
638            device_type: "nuimo".into(),
639            device_id: "C3:81:DF:4E".into(),
640            service_type: "roon".into(),
641            service_target: "zone-1".into(),
642            routes: vec![Route {
643                input: "rotate".into(),
644                intent: "volume_change".into(),
645                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
646            }],
647            feedback: vec![],
648            active: true,
649            target_candidates: vec![],
650            target_switch_on: None,
651        }
652    }
653
654    #[tokio::test]
655    async fn rotate_produces_volume_change_with_damping() {
656        let engine = RoutingEngine::new();
657        engine.replace_all(vec![rotate_mapping()]).await;
658
659        let out = engine
660            .route(
661                "nuimo",
662                "C3:81:DF:4E",
663                &InputPrimitive::Rotate { delta: 0.03 },
664            )
665            .await;
666        assert_eq!(out.len(), 1);
667        match &out[0].intent {
668            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
669            other => panic!("expected VolumeChange, got {:?}", other),
670        }
671        assert_eq!(out[0].service_type, "roon");
672        assert_eq!(out[0].service_target, "zone-1");
673    }
674
675    #[tokio::test]
676    async fn inactive_mappings_are_skipped() {
677        let mut m = rotate_mapping();
678        m.active = false;
679        let engine = RoutingEngine::new();
680        engine.replace_all(vec![m]).await;
681
682        let out = engine
683            .route(
684                "nuimo",
685                "C3:81:DF:4E",
686                &InputPrimitive::Rotate { delta: 0.03 },
687            )
688            .await;
689        assert!(out.is_empty());
690    }
691
692    #[tokio::test]
693    async fn unknown_device_returns_empty() {
694        let engine = RoutingEngine::new();
695        engine.replace_all(vec![rotate_mapping()]).await;
696
697        let out = engine
698            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
699            .await;
700        assert!(out.is_empty());
701    }
702
703    fn selection_mapping() -> Mapping {
704        let mut m = rotate_mapping();
705        m.service_target = "target-A".into();
706        m.target_switch_on = Some("swipe_up".into());
707        m.target_candidates = vec![
708            TargetCandidate {
709                target: "target-A".into(),
710                label: "A".into(),
711                glyph: "glyph-a".into(),
712                service_type: None,
713                routes: None,
714            },
715            TargetCandidate {
716                target: "target-B".into(),
717                label: "B".into(),
718                glyph: "glyph-b".into(),
719                service_type: None,
720                routes: None,
721            },
722        ];
723        m
724    }
725
726    /// A mapping whose primary target is a Roon zone and whose secondary
727    /// candidate is a Hue light with its own route table. Used to exercise
728    /// the `effective_for` cross-service override path.
729    fn cross_service_mapping() -> Mapping {
730        let mut m = rotate_mapping();
731        m.service_target = "roon-zone".into();
732        m.target_switch_on = Some("long_press".into());
733        m.target_candidates = vec![
734            TargetCandidate {
735                target: "roon-zone".into(),
736                label: "Roon".into(),
737                glyph: "roon".into(),
738                service_type: None,
739                routes: None,
740            },
741            TargetCandidate {
742                target: "hue-light".into(),
743                label: "Hue".into(),
744                glyph: "hue".into(),
745                service_type: Some("hue".into()),
746                routes: Some(vec![Route {
747                    input: "rotate".into(),
748                    intent: "brightness_change".into(),
749                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
750                }]),
751            },
752        ];
753        m
754    }
755
756    #[test]
757    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
758        let m = cross_service_mapping();
759        let (svc, routes) = m.effective_for("roon-zone");
760        assert_eq!(svc, "roon");
761        assert_eq!(routes.len(), 1);
762        assert_eq!(routes[0].intent, "volume_change");
763    }
764
765    #[test]
766    fn effective_for_returns_candidate_overrides_when_present() {
767        let m = cross_service_mapping();
768        let (svc, routes) = m.effective_for("hue-light");
769        assert_eq!(svc, "hue");
770        assert_eq!(routes.len(), 1);
771        assert_eq!(routes[0].intent, "brightness_change");
772    }
773
774    #[tokio::test]
775    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
776        let mut m = cross_service_mapping();
777        // Simulate the user having switched the active target to the Hue
778        // candidate (as if selection mode had committed on it).
779        m.service_target = "hue-light".into();
780
781        let engine = RoutingEngine::new();
782        engine.replace_all(vec![m]).await;
783
784        let out = engine
785            .route(
786                "nuimo",
787                "C3:81:DF:4E",
788                &InputPrimitive::Rotate { delta: 0.1 },
789            )
790            .await;
791        assert_eq!(out.len(), 1);
792        assert_eq!(out[0].service_type, "hue");
793        assert_eq!(out[0].service_target, "hue-light");
794        match &out[0].intent {
795            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
796            other => panic!("expected BrightnessChange, got {:?}", other),
797        }
798    }
799
800    #[tokio::test]
801    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
802        let engine = RoutingEngine::new();
803        engine.replace_all(vec![selection_mapping()]).await;
804
805        match engine
806            .route_with_mode(
807                "nuimo",
808                "C3:81:DF:4E",
809                &InputPrimitive::Swipe {
810                    direction: Direction::Up,
811                },
812            )
813            .await
814        {
815            RouteOutcome::EnterSelection { glyph, .. } => {
816                // Entering mode from target-A should point at target-B.
817                assert_eq!(glyph, "glyph-b");
818            }
819            other => panic!("expected EnterSelection, got {:?}", other),
820        }
821
822        match engine
823            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
824            .await
825        {
826            RouteOutcome::CommitSelection { service_target, .. } => {
827                assert_eq!(service_target, "target-B")
828            }
829            other => panic!("expected CommitSelection, got {:?}", other),
830        }
831    }
832
833    #[tokio::test]
834    async fn rotate_then_press_commits_advanced_candidate() {
835        let engine = RoutingEngine::new();
836        engine.replace_all(vec![selection_mapping()]).await;
837
838        // Enter: cursor jumps to target-B (position 1).
839        let _ = engine
840            .route_with_mode(
841                "nuimo",
842                "C3:81:DF:4E",
843                &InputPrimitive::Swipe {
844                    direction: Direction::Up,
845                },
846            )
847            .await;
848        // Rotate forward past the damping threshold — wraps from B back to A.
849        match engine
850            .route_with_mode(
851                "nuimo",
852                "C3:81:DF:4E",
853                &InputPrimitive::Rotate {
854                    delta: SELECTION_ROTATION_STEP,
855                },
856            )
857            .await
858        {
859            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
860            other => panic!("expected UpdateSelection, got {:?}", other),
861        }
862        match engine
863            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
864            .await
865        {
866            RouteOutcome::CommitSelection { service_target, .. } => {
867                assert_eq!(service_target, "target-A")
868            }
869            other => panic!("expected CommitSelection, got {:?}", other),
870        }
871    }
872
873    #[tokio::test]
874    async fn sub_threshold_rotate_keeps_cursor_still() {
875        let engine = RoutingEngine::new();
876        engine.replace_all(vec![selection_mapping()]).await;
877
878        // Enter selection at target-B.
879        let _ = engine
880            .route_with_mode(
881                "nuimo",
882                "C3:81:DF:4E",
883                &InputPrimitive::Swipe {
884                    direction: Direction::Up,
885                },
886            )
887            .await;
888
889        // A single small rotation accumulates but does not cross the
890        // threshold. Caller sees an empty Normal outcome — no LED push,
891        // no state change.
892        match engine
893            .route_with_mode(
894                "nuimo",
895                "C3:81:DF:4E",
896                &InputPrimitive::Rotate {
897                    delta: SELECTION_ROTATION_STEP / 2.0,
898                },
899            )
900            .await
901        {
902            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
903            other => panic!("expected Normal(empty), got {:?}", other),
904        }
905
906        // A second small rotation pushes the accumulator over the
907        // threshold — cursor moves.
908        match engine
909            .route_with_mode(
910                "nuimo",
911                "C3:81:DF:4E",
912                &InputPrimitive::Rotate {
913                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
914                },
915            )
916            .await
917        {
918            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
919            other => panic!("expected UpdateSelection, got {:?}", other),
920        }
921    }
922
923    #[tokio::test]
924    async fn large_rotate_produces_single_step_per_outcome() {
925        // advance() advances until the accumulator is below the threshold,
926        // but route_with_mode yields a single RouteOutcome per input event.
927        // Verify a 2.4-step delta lands on a cursor position consistent
928        // with two advances (not one, not four).
929        let engine = RoutingEngine::new();
930        engine.replace_all(vec![selection_mapping()]).await;
931
932        let _ = engine
933            .route_with_mode(
934                "nuimo",
935                "C3:81:DF:4E",
936                &InputPrimitive::Swipe {
937                    direction: Direction::Up,
938                },
939            )
940            .await;
941        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
942        // list wraps back to B. Use a delta that advances exactly twice
943        // to land deterministically.
944        match engine
945            .route_with_mode(
946                "nuimo",
947                "C3:81:DF:4E",
948                &InputPrimitive::Rotate {
949                    delta: SELECTION_ROTATION_STEP * 2.0,
950                },
951            )
952            .await
953        {
954            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
955            other => panic!("expected UpdateSelection, got {:?}", other),
956        }
957    }
958
959    fn playback_glyph_rule() -> FeedbackRule {
960        FeedbackRule {
961            state: "playback".into(),
962            feedback_type: "glyph".into(),
963            mapping: serde_json::json!({
964                "playing": "play",
965                "paused": "pause",
966                "stopped": "pause",
967            }),
968        }
969    }
970
971    fn mapping_with_feedback() -> Mapping {
972        let mut m = rotate_mapping();
973        m.feedback = vec![playback_glyph_rule()];
974        m
975    }
976
977    #[tokio::test]
978    async fn feedback_rules_match_primary_target() {
979        let engine = RoutingEngine::new();
980        engine.replace_all(vec![mapping_with_feedback()]).await;
981
982        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
983        assert_eq!(rules.len(), 1);
984        assert_eq!(rules[0].state, "playback");
985    }
986
987    #[tokio::test]
988    async fn feedback_rules_match_candidate_override_service_type() {
989        let mut m = mapping_with_feedback();
990        m.target_candidates = vec![TargetCandidate {
991            target: "hue-light-1".into(),
992            label: "Living".into(),
993            glyph: "link".into(),
994            service_type: Some("hue".into()),
995            routes: None,
996        }];
997        let engine = RoutingEngine::new();
998        engine.replace_all(vec![m]).await;
999
1000        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1001        assert_eq!(
1002            rules.len(),
1003            1,
1004            "candidate with overridden service_type inherits mapping feedback",
1005        );
1006    }
1007
1008    #[tokio::test]
1009    async fn feedback_rules_empty_for_unknown_target() {
1010        let engine = RoutingEngine::new();
1011        engine.replace_all(vec![mapping_with_feedback()]).await;
1012
1013        let rules = engine
1014            .feedback_rules_for_target("roon", "some-other-zone")
1015            .await;
1016        assert!(rules.is_empty());
1017    }
1018
1019    #[tokio::test]
1020    async fn feedback_rules_for_device_target_scopes_by_device() {
1021        // Two mappings for the same Roon zone, but on different Nuimos.
1022        // Only the device that owns the mapping should receive feedback.
1023        let mut m_a = mapping_with_feedback();
1024        m_a.device_id = "nuimo-a".into();
1025        let mut m_b = mapping_with_feedback();
1026        m_b.mapping_id = Uuid::new_v4();
1027        m_b.device_id = "nuimo-b".into();
1028        m_b.service_target = "zone-b".into();
1029
1030        let engine = RoutingEngine::new();
1031        engine.replace_all(vec![m_a, m_b]).await;
1032
1033        let rules_a = engine
1034            .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1035            .await;
1036        let rules_a = rules_a.expect("nuimo-a owns zone-1");
1037        assert_eq!(rules_a.len(), 1);
1038
1039        let rules_b_wrong = engine
1040            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1041            .await;
1042        assert!(
1043            rules_b_wrong.is_none(),
1044            "nuimo-b does not own zone-1 — must skip entirely",
1045        );
1046
1047        let rules_b_right = engine
1048            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1049            .await;
1050        let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1051        assert_eq!(rules_b_right.len(), 1);
1052    }
1053
1054    #[tokio::test]
1055    async fn feedback_rules_for_device_target_none_for_unknown_device() {
1056        let engine = RoutingEngine::new();
1057        engine.replace_all(vec![mapping_with_feedback()]).await;
1058
1059        let rules = engine
1060            .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1061            .await;
1062        assert!(
1063            rules.is_none(),
1064            "no mapping for unknown device — caller should skip",
1065        );
1066    }
1067
1068    #[tokio::test]
1069    async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1070        // A mapping exists for the device + target, but `feedback` is empty.
1071        // Result must be `Some(vec![])` so the caller falls back to
1072        // hardcoded defaults (preserves single-device behavior).
1073        let m = rotate_mapping(); // no feedback rules
1074        let engine = RoutingEngine::new();
1075        engine.replace_all(vec![m]).await;
1076
1077        let rules = engine
1078            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1079            .await;
1080        let rules = rules.expect("mapping exists — caller should fall back to defaults");
1081        assert!(
1082            rules.is_empty(),
1083            "no explicit rules configured — pump will use FeedbackPlan defaults",
1084        );
1085    }
1086}