Skip to main content

irbt/
lib.rs

1//! Host-side client for the ir-bt-bridge USB configuration/control protocol.
2//!
3//! ```no_run
4//! # fn main() -> Result<(), irbt::Error> {
5//! let device = irbt::Device::open(None)?; // auto-detect the bridge
6//! device.set_mapping(0x00FF_02FD, irbt::HidKey::Home)?;
7//! for mapping in device.keymap()? {
8//!     println!("0x{:08X} -> {}", mapping.ir, mapping.key);
9//! }
10//! # Ok(())
11//! # }
12//! ```
13
14use std::collections::HashMap;
15use std::io::{self, Read, Write};
16use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
17use std::sync::{mpsc, Arc, Mutex};
18use std::thread;
19use std::time::{Duration, Instant};
20
21use irbt_proto::{Command, Decoded, Decoder, Frame, Reply, Request};
22
23pub use irbt_proto as proto;
24pub use irbt_proto::{
25    Battery, BuildInfo, Component, DeviceInfo, Event, HidKey, IrCode, Log, LogLevel, Mapping,
26    Status, PROTOCOL_VERSION,
27};
28
29/// Espressif's USB VID and the PID of the ESP32-S3's built-in USB-Serial-JTAG.
30pub const USB_VID: u16 = 0x303A;
31pub const USB_PID: u16 = 0x1001;
32
33/// How long to wait for a response to a single request.
34pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
35
36/// Serial read timeout; doubles as the idle period after which buffered
37/// non-frame bytes are delivered as [`Notification::Raw`].
38const READ_TIMEOUT: Duration = Duration::from_millis(50);
39
40/// Unread notifications beyond this are dropped rather than buffered forever.
41const NOTIFICATION_BACKLOG: usize = 1024;
42
43#[derive(Debug, thiserror::Error)]
44pub enum Error {
45    #[error("I/O error: {0}")]
46    Io(#[from] io::Error),
47    #[error("serial port error: {0}")]
48    Serial(#[from] serialport::Error),
49    #[error("failed to encode request: {0}")]
50    Encode(irbt_proto::EncodeError),
51    #[error("no ir-bt-bridge found (looking for USB {USB_VID:04x}:{USB_PID:04x}); pass a port explicitly")]
52    NoDevice,
53    #[error("several candidate devices found ({}); pass a port explicitly", .0.join(", "))]
54    MultipleDevices(Vec<String>),
55    #[error("device speaks protocol v{device}, this tool speaks v{host}; update the firmware or the tool")]
56    ProtocolMismatch { device: u16, host: u16 },
57    #[error("timed out waiting for the device")]
58    Timeout,
59    #[error("device disconnected")]
60    Disconnected,
61    #[error("device error: {0}")]
62    Device(String),
63    #[error("unexpected reply from device: {0:?}")]
64    UnexpectedReply(Reply),
65}
66
67pub type Result<T, E = Error> = std::result::Result<T, E>;
68
69/// Something the device sent without being asked.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Notification {
72    Event(Event),
73    Log(Log),
74    /// Output that isn't framed: ROM bootloader text, panic dumps, or a
75    /// corrupted frame. Decoded lossily as UTF-8.
76    Raw(String),
77}
78
79type Pending = Arc<Mutex<HashMap<u32, mpsc::Sender<Result<Reply, String>>>>>;
80
81/// A connection to one bridge.
82pub struct Device {
83    writer: Mutex<Box<dyn Write + Send>>,
84    pending: Pending,
85    notifications: Mutex<mpsc::Receiver<Notification>>,
86    alive: Arc<AtomicBool>,
87    next_id: AtomicU32,
88    timeout: Duration,
89}
90
91/// Serial port paths of every connected device that looks like a bridge.
92///
93/// # Errors
94///
95/// [`Error::Serial`] if the serial ports can't be enumerated.
96pub fn find_ports() -> Result<Vec<String>> {
97    Ok(serialport::available_ports()?
98        .into_iter()
99        .filter(|port| {
100            matches!(&port.port_type, serialport::SerialPortType::UsbPort(usb)
101                if usb.vid == USB_VID && usb.pid == USB_PID)
102        })
103        .map(|port| port.port_name)
104        .collect())
105}
106
107fn find_single_port() -> Result<String, Error> {
108    let mut ports = find_ports()?;
109    if ports.len() > 1 {
110        return Err(Error::MultipleDevices(ports));
111    }
112    ports.pop().ok_or(Error::NoDevice)
113}
114
115impl Device {
116    /// Open the bridge at `port`, or auto-detect it when `None`, and check
117    /// that it speaks this library's protocol version.
118    ///
119    /// # Errors
120    ///
121    /// [`Error::NoDevice`] or [`Error::MultipleDevices`] when auto-detecting
122    /// doesn't find exactly one bridge, [`Error::Serial`]/[`Error::Io`] if
123    /// the port can't be opened or configured,
124    /// [`Error::ProtocolMismatch`] if the firmware speaks another protocol
125    /// version, and any error from [`Device::info`].
126    pub fn open(port: Option<&str>) -> Result<Self> {
127        let path = match port {
128            Some(path) => path.to_owned(),
129            None => find_single_port()?,
130        };
131
132        let mut port = serialport::new(&path, 115_200)
133            .timeout(READ_TIMEOUT)
134            .open()?;
135        // The USB-Serial-JTAG resets the chip when RTS is asserted while DTR
136        // is not (the classic auto-reset circuit, emulated). Drop RTS first so
137        // that state is never passed through.
138        port.write_request_to_send(false)?;
139        port.write_data_terminal_ready(false)?;
140
141        let reader = port.try_clone()?;
142        let device = Self::from_io(reader, port);
143        device.handshake()?;
144        Ok(device)
145    }
146
147    /// Talk to a device over an arbitrary byte stream (tests, TCP bridges, …).
148    /// No handshake is performed; call [`Device::info`] to check the version.
149    ///
150    /// Reads returning [`io::ErrorKind::TimedOut`] or
151    /// [`io::ErrorKind::WouldBlock`] are treated as idle periods; EOF or any
152    /// other error ends the connection.
153    ///
154    /// # Panics
155    ///
156    /// If the OS can't spawn the reader thread.
157    pub fn from_io(
158        reader: impl Read + Send + 'static,
159        writer: impl Write + Send + 'static,
160    ) -> Self {
161        let pending = Pending::default();
162        let alive = Arc::new(AtomicBool::new(true));
163        let (tx, rx) = mpsc::sync_channel(NOTIFICATION_BACKLOG);
164
165        {
166            let pending = pending.clone();
167            let alive = alive.clone();
168            thread::spawn(move || {
169                read_loop(reader, &pending, &tx);
170                alive.store(false, Ordering::SeqCst);
171                // Dropping the senders wakes every waiting caller.
172                pending.lock().unwrap().clear();
173            });
174        }
175
176        Self {
177            writer: Mutex::new(Box::new(writer)),
178            pending,
179            notifications: Mutex::new(rx),
180            alive,
181            next_id: AtomicU32::new(1),
182            timeout: DEFAULT_TIMEOUT,
183        }
184    }
185
186    pub fn set_timeout(&mut self, timeout: Duration) {
187        self.timeout = timeout;
188    }
189
190    fn handshake(&self) -> Result<DeviceInfo> {
191        let info = self.info()?;
192        if info.protocol_version != PROTOCOL_VERSION {
193            return Err(Error::ProtocolMismatch {
194                device: info.protocol_version,
195                host: PROTOCOL_VERSION,
196            });
197        }
198        Ok(info)
199    }
200
201    /// Send one command and wait for its reply.
202    ///
203    /// # Errors
204    ///
205    /// [`Error::Encode`] if the request can't be encoded, [`Error::Io`] if
206    /// writing fails, [`Error::Timeout`] if no reply arrives in time,
207    /// [`Error::Disconnected`] if the connection is gone, or
208    /// [`Error::Device`] if the device reports an error.
209    ///
210    /// # Panics
211    ///
212    /// If an internal lock is poisoned (another thread panicked holding it).
213    pub fn call(&self, command: Command) -> Result<Reply> {
214        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
215        let wire =
216            irbt_proto::encode(&Frame::Request(Request { id, command })).map_err(Error::Encode)?;
217
218        let (tx, rx) = mpsc::channel();
219        self.pending.lock().unwrap().insert(id, tx);
220        let forget = || {
221            self.pending.lock().unwrap().remove(&id);
222        };
223
224        if !self.alive.load(Ordering::SeqCst) {
225            forget();
226            return Err(Error::Disconnected);
227        }
228        let written = {
229            let mut writer = self.writer.lock().unwrap();
230            writer.write_all(&wire).and_then(|()| writer.flush())
231        };
232        if let Err(e) = written {
233            forget();
234            return Err(e.into());
235        }
236
237        match rx.recv_timeout(self.timeout) {
238            Ok(Ok(reply)) => Ok(reply),
239            Ok(Err(message)) => Err(Error::Device(message)),
240            Err(mpsc::RecvTimeoutError::Timeout) => {
241                forget();
242                Err(Error::Timeout)
243            }
244            Err(mpsc::RecvTimeoutError::Disconnected) => Err(Error::Disconnected),
245        }
246    }
247
248    fn call_ok(&self, command: Command) -> Result<()> {
249        match self.call(command)? {
250            Reply::Ok => Ok(()),
251            other => Err(Error::UnexpectedReply(other)),
252        }
253    }
254
255    /// Check that the device is responding.
256    ///
257    /// # Errors
258    ///
259    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
260    /// with the wrong reply type.
261    pub fn ping(&self) -> Result<()> {
262        match self.call(Command::Ping)? {
263            Reply::Pong => Ok(()),
264            other => Err(Error::UnexpectedReply(other)),
265        }
266    }
267
268    /// Firmware and protocol version.
269    ///
270    /// # Errors
271    ///
272    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
273    /// with the wrong reply type.
274    pub fn info(&self) -> Result<DeviceInfo> {
275        match self.call(Command::GetInfo)? {
276            Reply::Info(info) => Ok(info),
277            other => Err(Error::UnexpectedReply(other)),
278        }
279    }
280
281    /// Git commit, toolchain and component versions the firmware was built
282    /// with. Firmware older than this command drops the request, so this
283    /// times out on it; check [`DeviceInfo::firmware_version`] first.
284    ///
285    /// # Errors
286    ///
287    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
288    /// with the wrong reply type.
289    pub fn build_info(&self) -> Result<BuildInfo> {
290        match self.call(Command::GetBuildInfo)? {
291            Reply::BuildInfo(info) => Ok(info),
292            other => Err(Error::UnexpectedReply(other)),
293        }
294    }
295
296    /// Uptime, BLE state, mapping count and current mode.
297    ///
298    /// # Errors
299    ///
300    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
301    /// with the wrong reply type.
302    pub fn status(&self) -> Result<Status> {
303        match self.call(Command::GetStatus)? {
304            Reply::Status(status) => Ok(status),
305            other => Err(Error::UnexpectedReply(other)),
306        }
307    }
308
309    /// The full keymap, fetched page by page.
310    ///
311    /// # Errors
312    ///
313    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
314    /// with the wrong reply type.
315    pub fn keymap(&self) -> Result<Vec<Mapping>> {
316        let mut mappings = Vec::new();
317        loop {
318            let offset = u16::try_from(mappings.len()).unwrap_or(u16::MAX);
319            let page = match self.call(Command::ListKeymap { offset })? {
320                Reply::Keymap(page) => page,
321                other => return Err(Error::UnexpectedReply(other)),
322            };
323            let done = page.entries.is_empty()
324                || usize::from(page.offset) + page.entries.len() >= usize::from(page.total);
325            mappings.extend(page.entries);
326            if done {
327                return Ok(mappings);
328            }
329        }
330    }
331
332    /// Map `ir` to `key`, replacing any existing mapping for `ir`.
333    ///
334    /// # Errors
335    ///
336    /// See [`Device::call`]; [`Error::Device`] also covers a full keymap.
337    pub fn set_mapping(&self, ir: IrCode, key: HidKey) -> Result<()> {
338        self.call_ok(Command::SetMapping(Mapping { ir, key }))
339    }
340
341    /// Remove the mapping for `ir`.
342    ///
343    /// # Errors
344    ///
345    /// See [`Device::call`]; [`Error::Device`] also covers `ir` not being mapped.
346    pub fn remove_mapping(&self, ir: IrCode) -> Result<()> {
347        self.call_ok(Command::RemoveMapping { ir })
348    }
349
350    /// Remove every mapping.
351    ///
352    /// # Errors
353    ///
354    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
355    /// with the wrong reply type.
356    pub fn clear_keymap(&self) -> Result<()> {
357        self.call_ok(Command::ClearKeymap)
358    }
359
360    /// Restore the firmware's built-in default keymap.
361    ///
362    /// # Errors
363    ///
364    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
365    /// with the wrong reply type.
366    pub fn reset_keymap(&self) -> Result<()> {
367        self.call_ok(Command::ResetKeymap)
368    }
369
370    /// Put the device in learn mode and block until the next IR code is
371    /// bound to `key`. Returns `None` if no code arrived within `timeout`.
372    ///
373    /// Other notifications received while waiting are discarded.
374    ///
375    /// # Errors
376    ///
377    /// See [`Device::call`]; [`Error::Device`] if the code couldn't be stored
378    /// (e.g. keymap full), [`Error::Timeout`] if the device never reports
379    /// the outcome.
380    pub fn learn(&self, key: HidKey, timeout: Duration) -> Result<Option<Mapping>> {
381        let timeout_ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX);
382        self.call_ok(Command::Learn { key, timeout_ms })?;
383
384        // The device enforces `timeout`; the extra margin covers latency.
385        let deadline = Instant::now() + timeout + self.timeout;
386        loop {
387            let left = deadline.saturating_duration_since(Instant::now());
388            match self.next_notification(left)? {
389                Some(Notification::Event(Event::Learned(mapping))) => return Ok(Some(mapping)),
390                Some(Notification::Event(Event::LearnTimedOut)) => return Ok(None),
391                Some(Notification::Event(Event::LearnFailed { reason })) => {
392                    return Err(Error::Device(reason))
393                }
394                Some(_) => {}
395                None => return Err(Error::Timeout),
396            }
397        }
398    }
399
400    /// Leave learn mode without binding anything.
401    ///
402    /// # Errors
403    ///
404    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
405    /// with the wrong reply type.
406    pub fn cancel_learn(&self) -> Result<()> {
407        self.call_ok(Command::CancelLearn)
408    }
409
410    /// Press and release `key` over BLE, as if a mapped IR code arrived.
411    ///
412    /// # Errors
413    ///
414    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
415    /// with the wrong reply type.
416    pub fn send_hid(&self, key: HidKey) -> Result<()> {
417        self.call_ok(Command::SendHid { key })
418    }
419
420    /// Transmit a NEC code through the IR blaster.
421    ///
422    /// # Errors
423    ///
424    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
425    /// with the wrong reply type.
426    pub fn send_ir(&self, code: IrCode) -> Result<()> {
427        self.call_ok(Command::SendIr { code })
428    }
429
430    /// Enable/disable [`Event::IrReceived`] notifications.
431    ///
432    /// # Errors
433    ///
434    /// See [`Device::call`]; [`Error::UnexpectedReply`] if the device answers
435    /// with the wrong reply type.
436    pub fn set_monitor(&self, enabled: bool) -> Result<()> {
437        self.call_ok(Command::SetMonitor { enabled })
438    }
439
440    /// Wait up to `timeout` for the next event, log line or raw output.
441    /// `Ok(None)` means nothing arrived in time.
442    ///
443    /// # Errors
444    ///
445    /// [`Error::Disconnected`] once the connection is gone and every
446    /// buffered notification has been read.
447    ///
448    /// # Panics
449    ///
450    /// If an internal lock is poisoned (another thread panicked holding it).
451    pub fn next_notification(&self, timeout: Duration) -> Result<Option<Notification>> {
452        match self.notifications.lock().unwrap().recv_timeout(timeout) {
453            Ok(notification) => Ok(Some(notification)),
454            Err(mpsc::RecvTimeoutError::Timeout) => Ok(None),
455            Err(mpsc::RecvTimeoutError::Disconnected) => Err(Error::Disconnected),
456        }
457    }
458}
459
460fn read_loop(mut reader: impl Read, pending: &Pending, notify: &mpsc::SyncSender<Notification>) {
461    let mut decoder = Decoder::new();
462    let mut buf = [0u8; 512];
463
464    let mut dispatch = |decoded: Decoded| {
465        let notification = match decoded {
466            Decoded::Frame(Frame::Response(response)) => {
467                // A missing entry means the caller already timed out.
468                if let Some(tx) = pending.lock().unwrap().remove(&response.id) {
469                    let _ = tx.send(response.result);
470                }
471                return;
472            }
473            Decoded::Frame(Frame::Event(event)) => Notification::Event(event),
474            Decoded::Frame(Frame::Log(log)) => Notification::Log(log),
475            Decoded::Frame(Frame::Request(_)) => return,
476            Decoded::Raw(bytes) => Notification::Raw(String::from_utf8_lossy(&bytes).into_owned()),
477        };
478        // Full means nobody is consuming notifications; dropping is fine.
479        let _ = notify.try_send(notification);
480    };
481
482    loop {
483        match reader.read(&mut buf) {
484            Ok(0) => break,
485            Ok(n) => buf[..n]
486                .iter()
487                .filter_map(|&byte| decoder.push(byte))
488                .for_each(&mut dispatch),
489            Err(e)
490                if matches!(
491                    e.kind(),
492                    io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
493                ) =>
494            {
495                decoder.flush().into_iter().for_each(&mut dispatch);
496            }
497            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
498            Err(_) => break,
499        }
500    }
501    decoder.flush().into_iter().for_each(dispatch);
502}
503
504#[cfg(test)]
505mod tests;