Skip to main content

ados_protocol/
buttons.rs

1//! Front-panel button events as they reach a plugin.
2//!
3//! The physical read, debounce and short/long decode all belong to `ados-hid`,
4//! which publishes each release-edge event on the button fanout socket. This
5//! module is only the plugin-facing half: the two method names and the shape of
6//! the event the host pushes.
7//!
8//! It lives here rather than in the host or either SDK because three separate
9//! implementations have to agree on it — the Rust host that pushes, and the
10//! Python and Rust SDKs that receive — and a wire contract with three
11//! independent spellings is a contract that drifts.
12//!
13//! # Why a push and not a poll
14//!
15//! A button press is an edge, not a level: a plugin that polls either misses
16//! presses between polls or has to be told about them anyway. So the host arms a
17//! per-connection stream on [`SUBSCRIBE`] and pushes a [`DELIVER`] event per
18//! press, the same shape the vision subscriptions use.
19//!
20//! # What a subscriber does and does not get
21//!
22//! Read-only, and deliberately not exclusive. Several consumers watch the same
23//! bus — the display navigator and the websocket relay among them — so a
24//! subscribing plugin observes presses and neither consumes them nor remaps
25//! them. A plugin that wants a button to stop doing its normal thing has to be
26//! given that by configuration, not by subscribing.
27
28use serde::{Deserialize, Serialize};
29
30/// Wire name of the subscribe request.
31pub const SUBSCRIBE: &str = "button.subscribe";
32
33/// Wire name of the pushed event. Carries a [`ButtonPress`] under `press`.
34pub const DELIVER: &str = "button.deliver";
35
36/// One decoded front-panel press.
37///
38/// `pin` is the board GPIO line (BCM on a Raspberry Pi, the Linux GPIO number
39/// elsewhere); `label` is the front-panel button (`B1`..`B4`, the key the button
40/// mapping uses), which stays the same across boards, so a subscriber that
41/// means "the first panel button" matches on the label. `action` is the host's
42/// live mapping for that (button, kind) pair and is `None` for an unmapped
43/// button — a subscriber sees the press either way and decides for itself
44/// whether an unmapped button means anything.
45///
46/// A board with a rotary encoder delivers it through the same event: one detent
47/// is a `short` press labelled `ENC_CW` or `ENC_CCW` (`pin` is the encoder's A
48/// line), and its push switch is a `short`/`long` press labelled `ENC_SW`.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ButtonPress {
51    /// Board GPIO line of the button.
52    pub pin: u32,
53    /// Front-panel button label (`B1`..`B4`, `BX<pin>` for an extra button,
54    /// `ENC_CW` / `ENC_CCW` / `ENC_SW` for the rotary encoder); `None` from a
55    /// host that predates the field.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub label: Option<String>,
58    /// `"short"` or `"long"`, decoded by the host on the release edge.
59    pub kind: String,
60    /// Resolved action name from the live mapping; `None` when unmapped.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub action: Option<String>,
63    /// Release-edge timestamp, monotonic milliseconds since the reader started.
64    #[serde(default)]
65    pub timestamp_ms: u64,
66}
67
68impl ButtonPress {
69    /// True when this is a long press.
70    ///
71    /// A helper rather than an enum on the wire: the host's own bus carries the
72    /// kind as a string, and re-typing it here would mean a third spelling to
73    /// keep in step for no gain.
74    pub fn is_long(&self) -> bool {
75        self.kind == "long"
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn an_unmapped_button_round_trips_without_an_action_field() {
85        let press = ButtonPress {
86            pin: 5,
87            label: None,
88            kind: "short".to_string(),
89            action: None,
90            timestamp_ms: 12,
91        };
92        let json = serde_json::to_string(&press).unwrap();
93        assert!(
94            !json.contains("action"),
95            "an absent action is omitted, not sent as null: {json}"
96        );
97        assert_eq!(serde_json::from_str::<ButtonPress>(&json).unwrap(), press);
98    }
99
100    #[test]
101    fn a_mapped_button_round_trips_with_its_action() {
102        let press = ButtonPress {
103            pin: 13,
104            label: Some("B3".to_string()),
105            kind: "long".to_string(),
106            action: Some("pair_drone".to_string()),
107            timestamp_ms: 4242,
108        };
109        let json = serde_json::to_string(&press).unwrap();
110        assert_eq!(serde_json::from_str::<ButtonPress>(&json).unwrap(), press);
111        assert!(press.is_long());
112    }
113
114    #[test]
115    fn an_explicit_null_action_decodes_as_unmapped() {
116        // The host's fanout socket emits `"action": null` for an unmapped
117        // button, so the receiving side must accept that spelling too and not
118        // just the omitted form.
119        let press: ButtonPress =
120            serde_json::from_str(r#"{"pin":6,"kind":"short","action":null,"timestamp_ms":1}"#)
121                .unwrap();
122        assert_eq!(press.action, None);
123        assert!(!press.is_long());
124    }
125
126    #[test]
127    fn a_missing_timestamp_defaults_rather_than_failing_the_decode() {
128        let press: ButtonPress = serde_json::from_str(r#"{"pin":19,"kind":"short"}"#).unwrap();
129        assert_eq!(press.timestamp_ms, 0);
130    }
131}