openlogi_device/backend.rs
1//! The contract between OpenLogi's HID++ layer and the HID stack beneath it.
2//!
3//! [`HidBackend`] is the seam. Above it sits everything that knows HID++ and
4//! nothing about a host; below it sits one implementation per host HID API —
5//! `openlogi-hid` over `async-hid` today, a scripted device tree in tests, and
6//! WebHID under wasm if that is ever built.
7//!
8//! Its own dependencies stay host-free for the same reason, which CI's
9//! `wasm (portable crates)` job checks rather than trusts. The conversions
10//! *from* a backend's own types belong with that backend, never here.
11
12#![deny(missing_docs)]
13#![deny(rustdoc::bare_urls)]
14#![deny(rustdoc::broken_intra_doc_links)]
15
16use std::fmt;
17use std::sync::Arc;
18
19use futures_lite::Stream;
20use hidpp::async_trait;
21use hidpp::channel::HidppChannel;
22use thiserror::Error;
23
24/// A failure raised by the HID backend beneath the HID++ channel layer.
25///
26/// Deliberately narrow. The only distinction anything above the transport
27/// branches on is "the device is gone" versus everything else, so a backend
28/// collapses its own error taxonomy into these two variants and every caller
29/// stays backend-agnostic.
30#[derive(Debug, Error)]
31pub enum BackendError {
32 /// The device is unreachable — it vanished after being opened, or was
33 /// already gone when the open was attempted.
34 ///
35 /// The two are one case here: nothing in the crate treats them
36 /// differently, and a backend cannot always tell them apart.
37 #[error("the HID device is not connected")]
38 Disconnected,
39 /// Any other backend failure, carried as its message.
40 ///
41 /// Backend error types are neither `Serialize` nor uniform across
42 /// backends, so the text is the whole payload — nothing matches on it.
43 #[error("{0}")]
44 Backend(String),
45}
46
47/// A HID node appeared on or vanished from the OS device tree.
48///
49/// Deliberately carries no identity: every consumer reacts by re-enumerating,
50/// and a backend that can only report "something changed" must still be able
51/// to raise it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum HotplugEvent {
54 /// A device node was connected.
55 Connected,
56 /// A device node was disconnected.
57 Disconnected,
58}
59
60/// Opaque identity of one HID node, as the backend that enumerated it names it.
61///
62/// Distinct per OS device node while that node exists, so it keys the open
63/// channels and the per-node ledger. It is **not** a portable physical key —
64/// a hidraw path on Linux, a device path on Windows, an IOKit registry entry
65/// on macOS — and must never be persisted. Physical identity comes from the
66/// device's own serial or HID++ model info instead.
67#[derive(Clone, Debug, PartialEq, Eq, Hash)]
68pub struct NodeId(String);
69
70impl From<String> for NodeId {
71 fn from(id: String) -> Self {
72 Self(id)
73 }
74}
75
76impl fmt::Display for NodeId {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(&self.0)
79 }
80}
81
82/// One HID node as the backend reports it, before anything is opened.
83///
84/// These are the fields enumeration filters on and routes address by — the
85/// intersection every HID backend can supply, which is also all the layers
86/// above the transport ever read.
87#[derive(Clone, Debug)]
88pub struct NodeInfo {
89 /// Backend-assigned identity of this node.
90 pub id: NodeId,
91 /// HID vendor id of the device's manufacturer.
92 pub vendor_id: u16,
93 /// HID product id.
94 pub product_id: u16,
95 /// HID usage page of this node's top-level collection.
96 pub usage_page: u16,
97 /// HID usage id of this node's top-level collection.
98 pub usage_id: u16,
99 /// Human-readable device name.
100 pub name: String,
101 /// Human-readable manufacturer, when the backend reports one.
102 pub manufacturer: Option<String>,
103 /// Device serial number, when the device has one and the backend can read
104 /// it.
105 pub serial_number: Option<String>,
106}
107
108impl NodeInfo {
109 /// Stable opaque identity used by raw-device routes.
110 ///
111 /// Prefers the HID serial; otherwise retains the backend's node id as a
112 /// runtime identity. The latter is deliberately not a cross-machine
113 /// portable key, but it is stronger than enumeration order and lets
114 /// duplicate nodes be rejected deterministically.
115 #[must_use]
116 pub fn identity(&self) -> String {
117 self.serial_number
118 .as_deref()
119 .filter(|serial| !serial.is_empty())
120 .map_or_else(
121 || format!("id:{}", self.id),
122 |serial| format!("serial:{}", serial.to_ascii_lowercase()),
123 )
124 }
125}
126
127/// A stream of [`HotplugEvent`]s, boxed so [`HidBackend`] stays object-safe.
128pub type HotplugStream = Box<dyn Stream<Item = HotplugEvent> + Send + Unpin>;
129
130/// A raw output-report sink, for reports the HID++ framing cannot model.
131///
132/// The HID++ channel covers reports `0x10`/`0x11`/`0x12` with request/response
133/// correlation. A few devices need a bare output report written with no reply
134/// expected — Logitech's Litra lights, driven over their own vendor protocol —
135/// and that is all this is for.
136#[async_trait]
137pub trait RawWriter: Send + Sync {
138 /// Write one output report, report id included as the first byte.
139 async fn write_output_report(&mut self, report: &[u8]) -> Result<(), BackendError>;
140}
141
142/// The HID stack beneath OpenLogi's HID++ layer.
143///
144/// One implementation per host HID API. Everything above it — enumeration
145/// policy, the probe, the write layer, capture sessions — is expressed against
146/// this trait and holds none of the backend's own types, which is what lets a
147/// second implementation (a scripted device tree in tests, WebHID under wasm)
148/// drop in without touching that code.
149///
150/// Opening is only defined for a node a previous [`Self::enumerate`] reported:
151/// a backend may hold OS handles from that enumeration rather than re-finding
152/// the node, so an unknown [`NodeInfo`] is [`BackendError::Disconnected`].
153#[async_trait]
154pub trait HidBackend: Send + Sync {
155 /// Every HID node the host currently reports.
156 async fn enumerate(&self) -> Result<Vec<NodeInfo>, BackendError>;
157
158 /// The subset of [`Self::enumerate`] that can carry HID++ traffic.
159 ///
160 /// Separate from filtering in the caller because part of the answer is
161 /// platform knowledge the backend owns — on Linux the `hid-logitech-dj`
162 /// driver publishes a per-device child node that exposes the same vendor
163 /// collection as its receiver but must never be addressed directly.
164 async fn enumerate_hidpp(&self) -> Result<Vec<NodeInfo>, BackendError>;
165
166 /// Open `node` as a HID++ channel, or `None` if it does not speak HID++.
167 ///
168 /// The backend owns the framing details behind this: which report widths
169 /// the node carries, and on Windows the pairing of the separate short- and
170 /// long-report interfaces into one channel.
171 async fn open_hidpp(&self, node: &NodeInfo) -> Result<Option<Arc<HidppChannel>>, BackendError>;
172
173 /// Open `node` for raw output reports.
174 async fn open_raw_writer(&self, node: &NodeInfo) -> Result<Box<dyn RawWriter>, BackendError>;
175
176 /// Subscribe to node connect/disconnect events.
177 fn watch(&self) -> Result<HotplugStream, BackendError>;
178}
179
180/// Carries a backend failure across the IPC boundary as text.
181///
182/// [`WriteError`](openlogi_core::hid::WriteError) is `Serialize` and
183/// [`BackendError`] is not, so the message is the payload; the typed error is
184/// never matched on downstream.
185///
186/// The impl lives here rather than beside `WriteError` because [`BackendError`]
187/// is the local half — the orphan rule allows exactly one of the two homes, and
188/// `openlogi-core` must never depend on a backend.
189impl From<BackendError> for openlogi_core::hid::WriteError {
190 fn from(error: BackendError) -> Self {
191 Self::Hid(error.to_string())
192 }
193}
194
195/// Carries a backend failure across the IPC boundary as text, as
196/// [`From<BackendError> for WriteError`](BackendError) does for writes.
197impl From<BackendError> for openlogi_core::hid::PairingError {
198 fn from(error: BackendError) -> Self {
199 Self::Hid(error.to_string())
200 }
201}