Skip to main content

ryu_hardware/
nudge.rs

1//! Live display nudge: tell a connected device "your dashboard changed, re-poll
2//! now" when its bound dashboard's data updates (review gap #4).
3//!
4//! TRMNL devices poll the node for their display image. Without a push signal, an
5//! edit (a new widget, fresh widget data, a builder change from the desktop) would
6//! only appear on the next poll — up to `refresh_rate` seconds late. This loop
7//! closes that gap: it subscribes to the dashboard store's broadcast (the SAME
8//! stream the desktop Home grid reads over SSE) and, when a widget on a
9//! device-bound dashboard changes, sends the RHP `display` control message
10//! ([`RhpServerMsg::Display`]) over that device's live WS so it re-polls promptly.
11//!
12//! Cost discipline mirrors the dashboard refresh loop: the nudge is only sent to
13//! **connected** devices ([`session::live::is_connected`]), and it is **debounced**
14//! per device so a burst of widget updates collapses into one re-poll. The device
15//! still re-polls on its own `refresh_rate` cadence when offline, so a missed nudge
16//! is never a correctness problem — only a latency one.
17
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::{Duration, Instant};
21
22use crate::feed::DashboardFeed;
23use crate::protocol::{DeviceType, RhpServerMsg, Surface};
24use crate::session::live;
25use crate::store::DeviceStore;
26
27/// Minimum gap between two nudges to the same device, so a flurry of widget value
28/// changes (e.g. several widgets refreshing in one tick) collapses to one re-poll.
29const NUDGE_DEBOUNCE: Duration = Duration::from_secs(2);
30
31/// Spawn the hardware display-nudge loop. Call once at startup with the
32/// [`DashboardFeed`] (for change events + the device→dashboard bindings) and the
33/// device store (to resolve a device's panel surface). No-op-cheap when no devices
34/// are bound.
35///
36/// The feed's `subscribe_changes` owns any reconnect/backoff (an out-of-process
37/// dashboards sidecar can restart); this loop just drains the channel and, when it
38/// closes, exits — a missed nudge is latency-only (the device re-polls on its own
39/// cadence regardless).
40pub fn spawn(dashboards: Arc<dyn DashboardFeed>, devices: DeviceStore) {
41    tokio::spawn(async move {
42        let mut rx = dashboards.subscribe_changes().await;
43        let mut last_nudge: HashMap<String, Instant> = HashMap::new();
44        // Each yielded item is the id of a dashboard whose data/definition changed
45        // (only changes warranting a re-poll are emitted by the feed).
46        while let Some(dashboard_id) = rx.recv().await {
47            // Find which device(s) bind this dashboard.
48            let bindings = match dashboards.list_bindings().await {
49                Ok(b) => b,
50                Err(_) => continue,
51            };
52            for dd in bindings.iter().filter(|d| d.dashboard_id == dashboard_id) {
53                if !live::is_connected(&dd.device_id).await {
54                    continue;
55                }
56                // Debounce per device.
57                let now = Instant::now();
58                if let Some(prev) = last_nudge.get(&dd.device_id) {
59                    if now.duration_since(*prev) < NUDGE_DEBOUNCE {
60                        continue;
61                    }
62                }
63                let surface = surface_for(&devices, &dd.device_id).await;
64                let sent = live::send(
65                    &dd.device_id,
66                    RhpServerMsg::Display {
67                        surface,
68                        widget: "dashboard".to_string(),
69                        payload: serde_json::json!({ "action": "repoll" }),
70                    },
71                )
72                .await;
73                if sent {
74                    last_nudge.insert(dd.device_id.clone(), now);
75                }
76            }
77        }
78    });
79}
80
81/// Resolve the panel surface (`eink`/`lcd`) for a device from its class. The watch
82/// is the only LCD device; desk/necklace use e-ink. Defaults to e-ink when the
83/// device row can't be read (the firmware treats `eink` as the dashboard panel).
84async fn surface_for(devices: &DeviceStore, device_id: &str) -> Surface {
85    match devices.get(device_id).await {
86        Ok(Some(r)) if matches!(r.device_type, DeviceType::Watch) => Surface::Lcd,
87        _ => Surface::Eink,
88    }
89}