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}