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    /// If `input` matches the cycle gesture for the device's `DeviceCycle`,
254    /// advance `active_mapping_id` to the next entry locally and return
255    /// the new active id. Otherwise return `None` and leave state untouched.
256    ///
257    /// Callers (Nuimo / Tap Dial event loops on every platform) should
258    /// invoke this BEFORE `route()`. When it returns `Some(id)`, skip the
259    /// normal routing dispatch and instead emit
260    /// `EdgeToServer::SwitchActiveConnection { ..., active_mapping_id: id }`
261    /// so the server can persist + broadcast. This keeps cycle-gesture
262    /// detection at the engine layer (already correct) while leaving
263    /// callers free to use either `route()` or `route_with_mode()` for
264    /// non-gesture inputs.
265    pub async fn try_cycle_switch(
266        &self,
267        device_type: &str,
268        device_id: &str,
269        input: &InputPrimitive,
270    ) -> Option<Uuid> {
271        let key = (device_type.to_string(), device_id.to_string());
272        let cycle = self.cycles.read().await.get(&key).cloned()?;
273        let gesture = cycle.cycle_gesture.as_deref()?;
274        if !input.matches_route(gesture) {
275            return None;
276        }
277        let next = next_active(&cycle)?;
278        self.cycles
279            .write()
280            .await
281            .entry(key)
282            .and_modify(|c| c.active_mapping_id = Some(next));
283        Some(next)
284    }
285
286    /// Fetch a cycle by device key, if any.
287    pub async fn cycle_for(&self, device_type: &str, device_id: &str) -> Option<DeviceCycle> {
288        let key = (device_type.to_string(), device_id.to_string());
289        self.cycles.read().await.get(&key).cloned()
290    }
291
292    /// Feedback rules attached to whichever mapping covers the given
293    /// `(service_type, target)` — either as its primary target or as a
294    /// listed candidate (including candidates that override the mapping's
295    /// `service_type`). Returns an empty vec when no mapping covers the
296    /// target, letting the caller fall back to hardcoded defaults.
297    ///
298    /// Feedback is stored at the mapping level (all candidates share one
299    /// rule set), so a candidate match still returns the mapping's
300    /// `feedback`.
301    pub async fn feedback_rules_for_target(
302        &self,
303        service_type: &str,
304        target: &str,
305    ) -> Vec<FeedbackRule> {
306        let guard = self.by_device.read().await;
307        for list in guard.values() {
308            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
309                return rules;
310            }
311        }
312        Vec::new()
313    }
314
315    /// Same as `feedback_rules_for_target` but scoped to one `(device_type,
316    /// device_id)` bucket. Use this from per-device feedback pumps so a
317    /// Nuimo only reacts when the state change belongs to a mapping it
318    /// owns — otherwise a second Nuimo mapped elsewhere would flash glyphs
319    /// for unrelated service activity.
320    ///
321    /// Returns `None` when no mapping on this device covers
322    /// `(service_type, target)` — the caller should skip. Returns
323    /// `Some(vec)` when a mapping exists; the vec may be empty, which
324    /// signals "mapping exists but user configured no rules — fall back
325    /// to hardcoded defaults" (preserves single-device behavior).
326    pub async fn feedback_rules_for_device_target(
327        &self,
328        device_type: &str,
329        device_id: &str,
330        service_type: &str,
331        target: &str,
332    ) -> Option<Vec<FeedbackRule>> {
333        let guard = self.by_device.read().await;
334        let list = guard.get(&(device_type.to_string(), device_id.to_string()))?;
335        mapping_rules_for_target(list, service_type, target)
336    }
337
338    /// Find every device whose mapping owns `(service_type, target)` and
339    /// return its `(device_type, device_id, feedback_rules)`. Used by the
340    /// iOS feedback pump, which fans a single state update out to every
341    /// LED that should display it — typically 1 device, but two Nuimos
342    /// paired to the same iPad both deserve feedback.
343    pub async fn feedback_targets_for(
344        &self,
345        service_type: &str,
346        target: &str,
347    ) -> Vec<(String, String, Vec<FeedbackRule>)> {
348        let guard = self.by_device.read().await;
349        let mut out = Vec::new();
350        for ((device_type, device_id), list) in guard.iter() {
351            if let Some(rules) = mapping_rules_for_target(list, service_type, target) {
352                out.push((device_type.clone(), device_id.clone(), rules));
353            }
354        }
355        out
356    }
357
358    /// Apply the given input primitive from a specific device, returning every
359    /// intent it produces across all matching mappings.
360    ///
361    /// When the device has a `DeviceCycle`, only the mapping identified
362    /// by `active_mapping_id` is eligible — non-active members and
363    /// non-member mappings on the same device sit dormant. Cycle-gesture
364    /// inputs are NOT short-circuited here (they would silently dispatch
365    /// nothing); use `route_with_mode` to surface a `CycleSwitch` outcome.
366    pub async fn route(
367        &self,
368        device_type: &str,
369        device_id: &str,
370        input: &InputPrimitive,
371    ) -> Vec<RoutedIntent> {
372        let key = (device_type.to_string(), device_id.to_string());
373        let cycle = self.cycles.read().await.get(&key).cloned();
374        let guard = self.by_device.read().await;
375        let Some(mappings) = guard.get(&key) else {
376            return Vec::new();
377        };
378        let active_filter = active_filter_from_cycle(cycle.as_ref());
379        route_mappings(mappings, input, active_filter)
380    }
381
382    /// Same dispatch as `route`, but also implements:
383    /// - the device-level Cycle: cycle-gesture inputs short-circuit to a
384    ///   `CycleSwitch` outcome (active is advanced locally; caller relays
385    ///   to server). When a cycle exists, only the active mapping fires.
386    /// - the legacy `target_switch_on` + `target_candidates` selection
387    ///   mode for backward compat — only consulted when no cycle exists
388    ///   for the device.
389    pub async fn route_with_mode(
390        &self,
391        device_type: &str,
392        device_id: &str,
393        input: &InputPrimitive,
394    ) -> RouteOutcome {
395        let key = (device_type.to_string(), device_id.to_string());
396
397        // Cycle path: takes precedence over legacy selection mode.
398        // If the input matches `cycle_gesture`, advance active locally
399        // and return a CycleSwitch outcome. Otherwise apply the active
400        // filter to mapping routing.
401        {
402            let cycles = self.cycles.read().await;
403            if let Some(cycle) = cycles.get(&key).cloned() {
404                drop(cycles);
405                if let Some(gesture) = cycle.cycle_gesture.as_deref() {
406                    if input.matches_route(gesture) {
407                        // Compute next active without holding the read
408                        // lock through the write.
409                        let next = next_active(&cycle);
410                        if let Some(next_id) = next {
411                            // Advance local state; engine is the source
412                            // of truth until server confirms.
413                            self.cycles
414                                .write()
415                                .await
416                                .entry(key.clone())
417                                .and_modify(|c| {
418                                    c.active_mapping_id = Some(next_id);
419                                });
420                            return RouteOutcome::CycleSwitch {
421                                device_type: device_type.to_string(),
422                                device_id: device_id.to_string(),
423                                active_mapping_id: next_id,
424                            };
425                        }
426                    }
427                }
428                // Non-cycle-gesture input on a cycled device: filter
429                // to the active mapping and route normally. Skip the
430                // legacy selection-mode path entirely — devices on
431                // the cycle model don't enter selection mode.
432                let active_filter = active_filter_from_cycle(Some(&cycle));
433                let guard = self.by_device.read().await;
434                let Some(mappings) = guard.get(&key) else {
435                    return RouteOutcome::Normal(Vec::new());
436                };
437                return RouteOutcome::Normal(route_mappings(mappings, input, active_filter));
438            }
439        }
440
441        // Already in selection mode for this device: intercept rotate /
442        // press / cancel before dispatching to normal routing.
443        {
444            let mut sel = self.selection.write().await;
445            if let Some(mode) = sel.get_mut(&key) {
446                match input {
447                    InputPrimitive::Rotate { delta } => {
448                        // Sub-threshold rotations accumulate without
449                        // emitting UpdateSelection — the LED keeps the
450                        // current candidate's glyph and no log line
451                        // fires until the cursor actually moves.
452                        if !mode.advance(*delta) {
453                            return RouteOutcome::Normal(Vec::new());
454                        }
455                        let glyph = mode.current().glyph.clone();
456                        let mapping_id = mode.mapping_id;
457                        return RouteOutcome::UpdateSelection { mapping_id, glyph };
458                    }
459                    InputPrimitive::Press => {
460                        let mapping_id = mode.mapping_id;
461                        let service_target = mode.current().target.clone();
462                        let edge_id = mode.edge_id.clone();
463                        sel.remove(&key);
464                        return RouteOutcome::CommitSelection {
465                            edge_id,
466                            mapping_id,
467                            service_target,
468                        };
469                    }
470                    _ => {
471                        let mapping_id = mode.mapping_id;
472                        sel.remove(&key);
473                        return RouteOutcome::CancelSelection { mapping_id };
474                    }
475                }
476            }
477        }
478
479        // Not in mode yet: check whether this input should enter mode on
480        // any mapping for this device, else fall through to normal routing.
481        let guard = self.by_device.read().await;
482        let Some(mappings) = guard.get(&key) else {
483            return RouteOutcome::Normal(Vec::new());
484        };
485
486        for m in mappings {
487            if !m.active {
488                continue;
489            }
490            let Some(switch_on) = m.target_switch_on.as_deref() else {
491                continue;
492            };
493            if m.target_candidates.is_empty() {
494                continue;
495            }
496            if !input.matches_route(switch_on) {
497                continue;
498            }
499            // Enter mode with cursor one step AFTER the current
500            // service_target — so swipe_up→press (no rotate) cycles to
501            // the next candidate. Rotate still browses from there.
502            let current_idx = m
503                .target_candidates
504                .iter()
505                .position(|c| c.target == m.service_target);
506            let cursor = match current_idx {
507                Some(i) => (i + 1) % m.target_candidates.len(),
508                None => 0,
509            };
510            let mode = SelectionMode::new(
511                m.mapping_id,
512                m.edge_id.clone(),
513                m.target_candidates.clone(),
514                cursor,
515            );
516            let glyph = mode.current().glyph.clone();
517            let mapping_id = mode.mapping_id;
518            let edge_id = mode.edge_id.clone();
519            drop(guard);
520            self.selection.write().await.insert(key, mode);
521            return RouteOutcome::EnterSelection {
522                edge_id,
523                mapping_id,
524                glyph,
525            };
526        }
527
528        RouteOutcome::Normal(route_mappings(mappings, input, None))
529    }
530}
531
532/// Compute the next active mapping ID in the cycle order. Returns `None`
533/// when the cycle is empty or its current `active_mapping_id` does not
534/// match any entry (caller may want to reset to head, but this helper
535/// stays conservative).
536fn next_active(cycle: &DeviceCycle) -> Option<Uuid> {
537    if cycle.mapping_ids.is_empty() {
538        return None;
539    }
540    let active = cycle.active_mapping_id?;
541    let pos = cycle.mapping_ids.iter().position(|id| *id == active)?;
542    let next = (pos + 1) % cycle.mapping_ids.len();
543    Some(cycle.mapping_ids[next])
544}
545
546/// Extract the active filter UUID from a cycle if one exists. `None`
547/// means "no cycle — all matching mappings fire" (preserves backward
548/// compatibility for devices without a cycle).
549fn active_filter_from_cycle(cycle: Option<&DeviceCycle>) -> Option<Uuid> {
550    cycle.and_then(|c| c.active_mapping_id)
551}
552
553/// Look for a mapping in `list` whose primary `(service_type, target)` or
554/// any `target_candidates` entry matches the given pair. Returns the
555/// mapping's `feedback` clone on hit. `None` means no mapping covers the
556/// target, letting the caller fall back to defaults.
557///
558/// Inactive mappings are skipped — only the user-selected `active` mapping
559/// for a given (device, service, target) drives feedback. Without this
560/// filter, multiple Roon zones publishing simultaneously (Roon Core fans
561/// state for every observed zone) would each match an inactive Nuimo
562/// mapping and produce competing volume_bar / glyph renders on the same
563/// LED.
564fn mapping_rules_for_target(
565    list: &[Mapping],
566    service_type: &str,
567    target: &str,
568) -> Option<Vec<FeedbackRule>> {
569    for m in list {
570        if !m.active {
571            continue;
572        }
573        if m.service_type == service_type && m.service_target == target {
574            return Some(m.feedback.clone());
575        }
576        for c in &m.target_candidates {
577            let c_service_type = c.service_type.as_deref().unwrap_or(&m.service_type);
578            if c_service_type == service_type && c.target == target {
579                return Some(m.feedback.clone());
580            }
581        }
582    }
583    None
584}
585
586fn route_mappings(
587    mappings: &[Mapping],
588    input: &InputPrimitive,
589    active_filter: Option<Uuid>,
590) -> Vec<RoutedIntent> {
591    let mut out = Vec::new();
592    for m in mappings {
593        if !m.active {
594            continue;
595        }
596        // When a cycle is in effect, only the active mapping fires.
597        // Other cycle members + non-cycle mappings on this device are
598        // skipped. (The cycle's mapping_ids list is enforced on the
599        // active_filter side — only the active id will pass through.)
600        if let Some(active_id) = active_filter {
601            if m.mapping_id != active_id {
602                continue;
603            }
604        }
605        // Resolve the effective service_type + routes for the currently
606        // active target. When the active target is a cross-service
607        // candidate (e.g. Roon-flavoured mapping pointing at a Hue light),
608        // `effective_for` returns the candidate's overrides so the
609        // dispatcher sees the right adapter + intent set.
610        let (service_type, routes) = m.effective_for(&m.service_target);
611        for route in routes {
612            if !input.matches_route(&route.input) {
613                continue;
614            }
615            if let Some(intent) = build_intent(route, input) {
616                out.push(RoutedIntent {
617                    service_type: service_type.to_string(),
618                    service_target: m.service_target.clone(),
619                    intent,
620                });
621                // Only first matching route per mapping fires.
622                break;
623            }
624        }
625    }
626    out
627}
628
629fn build_intent(route: &Route, input: &InputPrimitive) -> Option<Intent> {
630    let damping = route
631        .params
632        .get("damping")
633        .and_then(|v| v.as_f64())
634        .unwrap_or(1.0);
635
636    match route.intent.as_str() {
637        "play" => Some(Intent::Play),
638        "pause" => Some(Intent::Pause),
639        "play_pause" | "playpause" => Some(Intent::PlayPause),
640        "stop" => Some(Intent::Stop),
641        "next" => Some(Intent::Next),
642        "previous" => Some(Intent::Previous),
643        "mute" => Some(Intent::Mute),
644        "unmute" => Some(Intent::Unmute),
645        "power_toggle" => Some(Intent::PowerToggle),
646        "power_on" => Some(Intent::PowerOn),
647        "power_off" => Some(Intent::PowerOff),
648        "volume_change" => input
649            .continuous_value()
650            .map(|v| Intent::VolumeChange { delta: v * damping }),
651        "volume_set" => route
652            .params
653            .get("value")
654            .and_then(|v| v.as_f64())
655            .map(|value| Intent::VolumeSet { value }),
656        "seek_relative" => input.continuous_value().map(|v| Intent::SeekRelative {
657            seconds: v * damping,
658        }),
659        "brightness_change" => input
660            .continuous_value()
661            .map(|v| Intent::BrightnessChange { delta: v * damping }),
662        "color_temperature_change" => input
663            .continuous_value()
664            .map(|v| Intent::ColorTemperatureChange { delta: v * damping }),
665        _ => None,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::Direction;
673    use std::collections::BTreeMap;
674    use uuid::Uuid;
675    use weave_contracts::Route;
676
677    fn rotate_mapping() -> Mapping {
678        Mapping {
679            mapping_id: Uuid::new_v4(),
680            edge_id: "living-room".into(),
681            device_type: "nuimo".into(),
682            device_id: "C3:81:DF:4E".into(),
683            service_type: "roon".into(),
684            service_target: "zone-1".into(),
685            routes: vec![Route {
686                input: "rotate".into(),
687                intent: "volume_change".into(),
688                params: BTreeMap::from([("damping".into(), serde_json::json!(80.0))]),
689            }],
690            feedback: vec![],
691            active: true,
692            target_candidates: vec![],
693            target_switch_on: None,
694        }
695    }
696
697    #[tokio::test]
698    async fn rotate_produces_volume_change_with_damping() {
699        let engine = RoutingEngine::new();
700        engine.replace_all(vec![rotate_mapping()]).await;
701
702        let out = engine
703            .route(
704                "nuimo",
705                "C3:81:DF:4E",
706                &InputPrimitive::Rotate { delta: 0.03 },
707            )
708            .await;
709        assert_eq!(out.len(), 1);
710        match &out[0].intent {
711            Intent::VolumeChange { delta } => assert!((*delta - 2.4).abs() < 0.001),
712            other => panic!("expected VolumeChange, got {:?}", other),
713        }
714        assert_eq!(out[0].service_type, "roon");
715        assert_eq!(out[0].service_target, "zone-1");
716    }
717
718    #[tokio::test]
719    async fn inactive_mappings_are_skipped() {
720        let mut m = rotate_mapping();
721        m.active = false;
722        let engine = RoutingEngine::new();
723        engine.replace_all(vec![m]).await;
724
725        let out = engine
726            .route(
727                "nuimo",
728                "C3:81:DF:4E",
729                &InputPrimitive::Rotate { delta: 0.03 },
730            )
731            .await;
732        assert!(out.is_empty());
733    }
734
735    #[tokio::test]
736    async fn unknown_device_returns_empty() {
737        let engine = RoutingEngine::new();
738        engine.replace_all(vec![rotate_mapping()]).await;
739
740        let out = engine
741            .route("nuimo", "unknown", &InputPrimitive::Rotate { delta: 0.03 })
742            .await;
743        assert!(out.is_empty());
744    }
745
746    fn selection_mapping() -> Mapping {
747        let mut m = rotate_mapping();
748        m.service_target = "target-A".into();
749        m.target_switch_on = Some("swipe_up".into());
750        m.target_candidates = vec![
751            TargetCandidate {
752                target: "target-A".into(),
753                label: "A".into(),
754                glyph: "glyph-a".into(),
755                service_type: None,
756                routes: None,
757            },
758            TargetCandidate {
759                target: "target-B".into(),
760                label: "B".into(),
761                glyph: "glyph-b".into(),
762                service_type: None,
763                routes: None,
764            },
765        ];
766        m
767    }
768
769    /// A mapping whose primary target is a Roon zone and whose secondary
770    /// candidate is a Hue light with its own route table. Used to exercise
771    /// the `effective_for` cross-service override path.
772    fn cross_service_mapping() -> Mapping {
773        let mut m = rotate_mapping();
774        m.service_target = "roon-zone".into();
775        m.target_switch_on = Some("long_press".into());
776        m.target_candidates = vec![
777            TargetCandidate {
778                target: "roon-zone".into(),
779                label: "Roon".into(),
780                glyph: "roon".into(),
781                service_type: None,
782                routes: None,
783            },
784            TargetCandidate {
785                target: "hue-light".into(),
786                label: "Hue".into(),
787                glyph: "hue".into(),
788                service_type: Some("hue".into()),
789                routes: Some(vec![Route {
790                    input: "rotate".into(),
791                    intent: "brightness_change".into(),
792                    params: BTreeMap::from([("damping".into(), serde_json::json!(50.0))]),
793                }]),
794            },
795        ];
796        m
797    }
798
799    #[test]
800    fn effective_for_returns_mapping_defaults_when_target_not_overridden() {
801        let m = cross_service_mapping();
802        let (svc, routes) = m.effective_for("roon-zone");
803        assert_eq!(svc, "roon");
804        assert_eq!(routes.len(), 1);
805        assert_eq!(routes[0].intent, "volume_change");
806    }
807
808    #[test]
809    fn effective_for_returns_candidate_overrides_when_present() {
810        let m = cross_service_mapping();
811        let (svc, routes) = m.effective_for("hue-light");
812        assert_eq!(svc, "hue");
813        assert_eq!(routes.len(), 1);
814        assert_eq!(routes[0].intent, "brightness_change");
815    }
816
817    #[tokio::test]
818    async fn rotate_on_cross_service_candidate_produces_hue_intent() {
819        let mut m = cross_service_mapping();
820        // Simulate the user having switched the active target to the Hue
821        // candidate (as if selection mode had committed on it).
822        m.service_target = "hue-light".into();
823
824        let engine = RoutingEngine::new();
825        engine.replace_all(vec![m]).await;
826
827        let out = engine
828            .route(
829                "nuimo",
830                "C3:81:DF:4E",
831                &InputPrimitive::Rotate { delta: 0.1 },
832            )
833            .await;
834        assert_eq!(out.len(), 1);
835        assert_eq!(out[0].service_type, "hue");
836        assert_eq!(out[0].service_target, "hue-light");
837        match &out[0].intent {
838            Intent::BrightnessChange { delta } => assert!((*delta - 5.0).abs() < 0.001),
839            other => panic!("expected BrightnessChange, got {:?}", other),
840        }
841    }
842
843    #[tokio::test]
844    async fn swipe_up_press_cycles_to_next_target_without_rotate() {
845        let engine = RoutingEngine::new();
846        engine.replace_all(vec![selection_mapping()]).await;
847
848        match engine
849            .route_with_mode(
850                "nuimo",
851                "C3:81:DF:4E",
852                &InputPrimitive::Swipe {
853                    direction: Direction::Up,
854                },
855            )
856            .await
857        {
858            RouteOutcome::EnterSelection { glyph, .. } => {
859                // Entering mode from target-A should point at target-B.
860                assert_eq!(glyph, "glyph-b");
861            }
862            other => panic!("expected EnterSelection, got {:?}", other),
863        }
864
865        match engine
866            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
867            .await
868        {
869            RouteOutcome::CommitSelection { service_target, .. } => {
870                assert_eq!(service_target, "target-B")
871            }
872            other => panic!("expected CommitSelection, got {:?}", other),
873        }
874    }
875
876    #[tokio::test]
877    async fn rotate_then_press_commits_advanced_candidate() {
878        let engine = RoutingEngine::new();
879        engine.replace_all(vec![selection_mapping()]).await;
880
881        // Enter: cursor jumps to target-B (position 1).
882        let _ = engine
883            .route_with_mode(
884                "nuimo",
885                "C3:81:DF:4E",
886                &InputPrimitive::Swipe {
887                    direction: Direction::Up,
888                },
889            )
890            .await;
891        // Rotate forward past the damping threshold — wraps from B back to A.
892        match engine
893            .route_with_mode(
894                "nuimo",
895                "C3:81:DF:4E",
896                &InputPrimitive::Rotate {
897                    delta: SELECTION_ROTATION_STEP,
898                },
899            )
900            .await
901        {
902            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
903            other => panic!("expected UpdateSelection, got {:?}", other),
904        }
905        match engine
906            .route_with_mode("nuimo", "C3:81:DF:4E", &InputPrimitive::Press)
907            .await
908        {
909            RouteOutcome::CommitSelection { service_target, .. } => {
910                assert_eq!(service_target, "target-A")
911            }
912            other => panic!("expected CommitSelection, got {:?}", other),
913        }
914    }
915
916    #[tokio::test]
917    async fn sub_threshold_rotate_keeps_cursor_still() {
918        let engine = RoutingEngine::new();
919        engine.replace_all(vec![selection_mapping()]).await;
920
921        // Enter selection at target-B.
922        let _ = engine
923            .route_with_mode(
924                "nuimo",
925                "C3:81:DF:4E",
926                &InputPrimitive::Swipe {
927                    direction: Direction::Up,
928                },
929            )
930            .await;
931
932        // A single small rotation accumulates but does not cross the
933        // threshold. Caller sees an empty Normal outcome — no LED push,
934        // no state change.
935        match engine
936            .route_with_mode(
937                "nuimo",
938                "C3:81:DF:4E",
939                &InputPrimitive::Rotate {
940                    delta: SELECTION_ROTATION_STEP / 2.0,
941                },
942            )
943            .await
944        {
945            RouteOutcome::Normal(routed) => assert!(routed.is_empty()),
946            other => panic!("expected Normal(empty), got {:?}", other),
947        }
948
949        // A second small rotation pushes the accumulator over the
950        // threshold — cursor moves.
951        match engine
952            .route_with_mode(
953                "nuimo",
954                "C3:81:DF:4E",
955                &InputPrimitive::Rotate {
956                    delta: SELECTION_ROTATION_STEP / 2.0 + 0.01,
957                },
958            )
959            .await
960        {
961            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-a"),
962            other => panic!("expected UpdateSelection, got {:?}", other),
963        }
964    }
965
966    #[tokio::test]
967    async fn large_rotate_produces_single_step_per_outcome() {
968        // advance() advances until the accumulator is below the threshold,
969        // but route_with_mode yields a single RouteOutcome per input event.
970        // Verify a 2.4-step delta lands on a cursor position consistent
971        // with two advances (not one, not four).
972        let engine = RoutingEngine::new();
973        engine.replace_all(vec![selection_mapping()]).await;
974
975        let _ = engine
976            .route_with_mode(
977                "nuimo",
978                "C3:81:DF:4E",
979                &InputPrimitive::Swipe {
980                    direction: Direction::Up,
981                },
982            )
983            .await;
984        // Starting cursor is at B (index 1). 2 steps forward in a 2-entry
985        // list wraps back to B. Use a delta that advances exactly twice
986        // to land deterministically.
987        match engine
988            .route_with_mode(
989                "nuimo",
990                "C3:81:DF:4E",
991                &InputPrimitive::Rotate {
992                    delta: SELECTION_ROTATION_STEP * 2.0,
993                },
994            )
995            .await
996        {
997            RouteOutcome::UpdateSelection { glyph, .. } => assert_eq!(glyph, "glyph-b"),
998            other => panic!("expected UpdateSelection, got {:?}", other),
999        }
1000    }
1001
1002    fn playback_glyph_rule() -> FeedbackRule {
1003        FeedbackRule {
1004            state: "playback".into(),
1005            feedback_type: "glyph".into(),
1006            mapping: serde_json::json!({
1007                "playing": "play",
1008                "paused": "pause",
1009                "stopped": "pause",
1010            }),
1011        }
1012    }
1013
1014    fn mapping_with_feedback() -> Mapping {
1015        let mut m = rotate_mapping();
1016        m.feedback = vec![playback_glyph_rule()];
1017        m
1018    }
1019
1020    #[tokio::test]
1021    async fn feedback_rules_match_primary_target() {
1022        let engine = RoutingEngine::new();
1023        engine.replace_all(vec![mapping_with_feedback()]).await;
1024
1025        let rules = engine.feedback_rules_for_target("roon", "zone-1").await;
1026        assert_eq!(rules.len(), 1);
1027        assert_eq!(rules[0].state, "playback");
1028    }
1029
1030    #[tokio::test]
1031    async fn feedback_rules_match_candidate_override_service_type() {
1032        let mut m = mapping_with_feedback();
1033        m.target_candidates = vec![TargetCandidate {
1034            target: "hue-light-1".into(),
1035            label: "Living".into(),
1036            glyph: "link".into(),
1037            service_type: Some("hue".into()),
1038            routes: None,
1039        }];
1040        let engine = RoutingEngine::new();
1041        engine.replace_all(vec![m]).await;
1042
1043        let rules = engine.feedback_rules_for_target("hue", "hue-light-1").await;
1044        assert_eq!(
1045            rules.len(),
1046            1,
1047            "candidate with overridden service_type inherits mapping feedback",
1048        );
1049    }
1050
1051    #[tokio::test]
1052    async fn feedback_rules_empty_for_unknown_target() {
1053        let engine = RoutingEngine::new();
1054        engine.replace_all(vec![mapping_with_feedback()]).await;
1055
1056        let rules = engine
1057            .feedback_rules_for_target("roon", "some-other-zone")
1058            .await;
1059        assert!(rules.is_empty());
1060    }
1061
1062    #[tokio::test]
1063    async fn feedback_rules_for_device_target_scopes_by_device() {
1064        // Two mappings for the same Roon zone, but on different Nuimos.
1065        // Only the device that owns the mapping should receive feedback.
1066        let mut m_a = mapping_with_feedback();
1067        m_a.device_id = "nuimo-a".into();
1068        let mut m_b = mapping_with_feedback();
1069        m_b.mapping_id = Uuid::new_v4();
1070        m_b.device_id = "nuimo-b".into();
1071        m_b.service_target = "zone-b".into();
1072
1073        let engine = RoutingEngine::new();
1074        engine.replace_all(vec![m_a, m_b]).await;
1075
1076        let rules_a = engine
1077            .feedback_rules_for_device_target("nuimo", "nuimo-a", "roon", "zone-1")
1078            .await;
1079        let rules_a = rules_a.expect("nuimo-a owns zone-1");
1080        assert_eq!(rules_a.len(), 1);
1081
1082        let rules_b_wrong = engine
1083            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-1")
1084            .await;
1085        assert!(
1086            rules_b_wrong.is_none(),
1087            "nuimo-b does not own zone-1 — must skip entirely",
1088        );
1089
1090        let rules_b_right = engine
1091            .feedback_rules_for_device_target("nuimo", "nuimo-b", "roon", "zone-b")
1092            .await;
1093        let rules_b_right = rules_b_right.expect("nuimo-b owns zone-b");
1094        assert_eq!(rules_b_right.len(), 1);
1095    }
1096
1097    #[tokio::test]
1098    async fn feedback_rules_for_device_target_none_for_unknown_device() {
1099        let engine = RoutingEngine::new();
1100        engine.replace_all(vec![mapping_with_feedback()]).await;
1101
1102        let rules = engine
1103            .feedback_rules_for_device_target("nuimo", "unknown-device", "roon", "zone-1")
1104            .await;
1105        assert!(
1106            rules.is_none(),
1107            "no mapping for unknown device — caller should skip",
1108        );
1109    }
1110
1111    #[tokio::test]
1112    async fn feedback_rules_for_device_target_some_empty_for_mapping_without_rules() {
1113        // A mapping exists for the device + target, but `feedback` is empty.
1114        // Result must be `Some(vec![])` so the caller falls back to
1115        // hardcoded defaults (preserves single-device behavior).
1116        let m = rotate_mapping(); // no feedback rules
1117        let engine = RoutingEngine::new();
1118        engine.replace_all(vec![m]).await;
1119
1120        let rules = engine
1121            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1122            .await;
1123        let rules = rules.expect("mapping exists — caller should fall back to defaults");
1124        assert!(
1125            rules.is_empty(),
1126            "no explicit rules configured — pump will use FeedbackPlan defaults",
1127        );
1128    }
1129
1130    #[tokio::test]
1131    async fn feedback_rules_for_device_target_skips_inactive_mapping() {
1132        // Regression: a Nuimo configured with an active mapping for
1133        // roon/zone-1 plus an inactive mapping for roon/zone-2 (the
1134        // user explicitly chose zone-1 as the active target). When
1135        // Roon Core publishes state for zone-2 simultaneously, the
1136        // pump must NOT render feedback for zone-2 — the inactive
1137        // mapping is dormant by user choice.
1138        let mut active_zone1 = mapping_with_feedback();
1139        active_zone1.service_target = "zone-1".into();
1140        active_zone1.active = true;
1141
1142        let mut inactive_zone2 = mapping_with_feedback();
1143        inactive_zone2.mapping_id = uuid::Uuid::new_v4();
1144        inactive_zone2.service_target = "zone-2".into();
1145        inactive_zone2.active = false;
1146
1147        let engine = RoutingEngine::new();
1148        engine.replace_all(vec![active_zone1, inactive_zone2]).await;
1149
1150        let rules_active = engine
1151            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-1")
1152            .await;
1153        assert!(
1154            rules_active.is_some(),
1155            "active mapping for zone-1 must still produce rules"
1156        );
1157
1158        let rules_inactive = engine
1159            .feedback_rules_for_device_target("nuimo", "C3:81:DF:4E", "roon", "zone-2")
1160            .await;
1161        assert!(
1162            rules_inactive.is_none(),
1163            "inactive mapping for zone-2 must NOT produce feedback rules — \
1164             dormant by user choice, no LED render",
1165        );
1166    }
1167
1168    #[tokio::test]
1169    async fn feedback_targets_for_skips_inactive_mappings() {
1170        // Single Nuimo with two mappings (active roon/zone-1, inactive
1171        // roon/zone-2). When state arrives for zone-2, the pump must
1172        // not pick up the inactive mapping's rules — otherwise both
1173        // zones drive the same LED, alternating volume_bar renders.
1174        let mut active_zone1 = mapping_with_feedback();
1175        active_zone1.service_target = "zone-1".into();
1176        active_zone1.active = true;
1177
1178        let mut inactive_zone2 = mapping_with_feedback();
1179        inactive_zone2.mapping_id = uuid::Uuid::new_v4();
1180        inactive_zone2.service_target = "zone-2".into();
1181        inactive_zone2.active = false;
1182
1183        let engine = RoutingEngine::new();
1184        engine.replace_all(vec![active_zone1, inactive_zone2]).await;
1185
1186        let targets_zone1 = engine.feedback_targets_for("roon", "zone-1").await;
1187        assert_eq!(
1188            targets_zone1.len(),
1189            1,
1190            "active zone-1 mapping yields one feedback target"
1191        );
1192
1193        let targets_zone2 = engine.feedback_targets_for("roon", "zone-2").await;
1194        assert!(
1195            targets_zone2.is_empty(),
1196            "inactive zone-2 mapping must NOT yield feedback targets"
1197        );
1198    }
1199}