Skip to main content

chessnut_move/transport/
btleplug.rs

1// Copyright 2026 Daymon Littrell-Reyes
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Optional [`btleplug`](https://docs.rs/btleplug/0.12/btleplug/) adapter for
16//! connected Chessnut Move peripherals.
17//!
18//! This module does not scan, select adapters, connect, or reconnect. After an
19//! application connects a peripheral, [`BtleplugTransport::new`] discovers and
20//! validates the characteristics needed by the runtime-neutral sessions or the
21//! Tokio actor.
22//!
23//! Use the
24//! [`btleplug::api::Manager`](https://docs.rs/btleplug/0.12/btleplug/api/trait.Manager.html)
25//! and
26//! [`btleplug::api::Central`](https://docs.rs/btleplug/0.12/btleplug/api/trait.Central.html)
27//! traits to select an adapter and scan. Use
28//! [`btleplug::api::Peripheral::connect`](https://docs.rs/btleplug/0.12/btleplug/api/trait.Peripheral.html#tymethod.connect)
29//! before constructing this adapter.
30//!
31//! # Examples
32//!
33//! Find the advertised board, connect it, and create a transport:
34//!
35//! ```no_run
36//! use std::error::Error;
37//! use std::io;
38//! use btleplug::api::{
39//!     Central, Manager as _, Peripheral as _, ScanFilter,
40//! };
41//! use btleplug::platform::{Manager, Peripheral};
42//! use chessnut_move::transport::btleplug::BtleplugTransport;
43//! use chessnut_move::transport::gatt::DEVICE_NAME;
44//!
45//! async fn connect() -> Result<BtleplugTransport<Peripheral>, Box<dyn Error>> {
46//!     let manager = Manager::new().await?;
47//!     let adapter = manager
48//!         .adapters()
49//!         .await?
50//!         .into_iter()
51//!         .next()
52//!         .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no BLE adapter"))?;
53//!
54//!     adapter.start_scan(ScanFilter::default()).await?;
55//!     let mut board = None;
56//!     for peripheral in adapter.peripherals().await? {
57//!         let is_move = peripheral
58//!             .properties()
59//!             .await?
60//!             .and_then(|properties| properties.local_name)
61//!             .is_some_and(|name| name == DEVICE_NAME);
62//!         if is_move {
63//!             board = Some(peripheral);
64//!             break;
65//!         }
66//!     }
67//!
68//!     let board = board
69//!         .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "board not found"))?;
70//!     board.connect().await?;
71//!     Ok(BtleplugTransport::new(board).await?)
72//! }
73//! ```
74
75use core::pin::Pin;
76
77use ::btleplug::api::{
78  Characteristic as BtleplugCharacteristic, Peripheral, ValueNotification, WriteType,
79};
80use futures_util::{Stream, StreamExt};
81use thiserror::Error;
82use uuid::Uuid;
83
84use crate::protocol::{Command, WriteKind};
85#[cfg(doc)]
86use crate::transport;
87#[cfg(feature = "tokio")]
88use crate::transport::TokioTransport;
89use crate::transport::gatt::Characteristic;
90use crate::transport::{AsyncTransport, Notification, NotificationSource};
91
92type NotificationStream = Pin<Box<dyn Stream<Item = ValueNotification> + Send>>;
93
94/// Errors from the optional `btleplug` transport adapter.
95#[derive(Debug, Error)]
96pub enum Error {
97  /// The underlying `btleplug` backend returned an error.
98  #[error(transparent)]
99  Backend(#[from] ::btleplug::Error),
100
101  /// Service discovery did not expose a required Move characteristic.
102  #[error("required Chessnut Move characteristic is missing: {0:?}")]
103  MissingCharacteristic(Characteristic),
104
105  /// The peripheral-wide notification stream ended.
106  #[error("the btleplug notification stream ended")]
107  NotificationStreamEnded,
108
109  /// A received notification cannot fit in the session buffer.
110  #[error("notification has {actual} bytes but the provided buffer holds {capacity}")]
111  NotificationTooLong {
112    /// Number of bytes in the received notification.
113    actual: usize,
114
115    /// Number of bytes available in the supplied buffer.
116    capacity: usize,
117  },
118
119  /// A notification came from a characteristic not used by this protocol.
120  #[error("received a notification from unexpected characteristic {0}")]
121  UnexpectedNotificationCharacteristic(Uuid),
122}
123
124/// An [`AsyncTransport`] adapter around a connected `btleplug` peripheral.
125///
126/// Device discovery and connection remain with the application. Construction
127/// discovers the peripheral's services, validates the three required
128/// characteristics, and opens its notification stream.
129///
130/// The adapter implements [`AsyncTransport`] and, with the `tokio` feature,
131/// `TokioTransport`.
132#[cfg_attr(
133  feature = "tokio",
134  doc = "See [`TokioTransport`](transport::tokio::TokioTransport) for the actor-compatible transport contract."
135)]
136pub struct BtleplugTransport<P> {
137  peripheral: P,
138  command_write: BtleplugCharacteristic,
139  position_notification: BtleplugCharacteristic,
140  command_response: BtleplugCharacteristic,
141  notifications: NotificationStream,
142}
143
144impl<P: Peripheral> BtleplugTransport<P> {
145  /// Creates an adapter around an already connected peripheral.
146  ///
147  /// Call
148  /// [`Peripheral::connect`](https://docs.rs/btleplug/0.12/btleplug/api/trait.Peripheral.html#tymethod.connect)
149  /// before this method. This method performs service discovery but does not
150  /// establish the Bluetooth connection.
151  ///
152  /// # Examples
153  ///
154  /// ```no_run
155  /// use btleplug::api::Peripheral;
156  /// use chessnut_move::transport::btleplug::{BtleplugTransport, Error};
157  ///
158  /// # async fn create<P: Peripheral>(
159  /// #     peripheral: P,
160  /// # ) -> Result<BtleplugTransport<P>, Error> {
161  /// BtleplugTransport::new(peripheral).await
162  /// # }
163  /// ```
164  ///
165  /// # Errors
166  ///
167  /// Returns [`Error::Backend`] when service discovery or notification-stream
168  /// creation fails. Returns [`Error::MissingCharacteristic`] when the
169  /// connected peripheral does not expose a required Move characteristic.
170  pub async fn new(peripheral: P) -> Result<Self, Error> {
171    debug_event!("discovering Chessnut Move GATT services");
172    peripheral.discover_services().await?;
173
174    let characteristics = peripheral.characteristics();
175    trace_event!(
176      characteristic_count = characteristics.len(),
177      "discovered peripheral characteristics"
178    );
179    let find = |characteristic: Characteristic| {
180      let uuid = Uuid::from_u128(characteristic.uuid_u128());
181      characteristics
182        .iter()
183        .find(|candidate| candidate.uuid == uuid)
184        .cloned()
185        .ok_or_else(|| {
186          warn_event!(
187            characteristic = ?characteristic,
188            "required Chessnut Move characteristic is missing"
189          );
190          Error::MissingCharacteristic(characteristic)
191        })
192    };
193
194    let command_write = find(Characteristic::CommandWrite)?;
195    let position_notification = find(Characteristic::PositionNotification)?;
196    let command_response = find(Characteristic::CommandResponse)?;
197    let notifications = peripheral.notifications().await?;
198    debug_event!("Chessnut Move GATT transport is ready");
199
200    Ok(Self {
201      peripheral,
202      command_write,
203      position_notification,
204      command_response,
205      notifications,
206    })
207  }
208
209  /// Returns a shared reference to the wrapped peripheral.
210  pub const fn peripheral(&self) -> &P {
211    &self.peripheral
212  }
213
214  /// Returns a mutable reference to the wrapped peripheral.
215  ///
216  /// Direct operations can change connection or subscription state expected by
217  /// the adapter.
218  pub fn peripheral_mut(&mut self) -> &mut P {
219    &mut self.peripheral
220  }
221
222  /// Consumes the adapter and returns the wrapped peripheral.
223  ///
224  /// This method does not unsubscribe or disconnect the peripheral.
225  pub fn into_peripheral(self) -> P {
226    self.peripheral
227  }
228
229  /// Maps a protocol notification source to its discovered btleplug value.
230  fn notification_characteristic(&self, source: NotificationSource) -> &BtleplugCharacteristic {
231    match source {
232      NotificationSource::Position => &self.position_notification,
233      NotificationSource::CommandResponse => &self.command_response,
234    }
235  }
236
237  /// Enables a discovered notification characteristic.
238  async fn subscribe_source(&mut self, source: NotificationSource) -> Result<(), Error> {
239    trace_event!(source = ?source, "subscribing to BLE notifications");
240    self
241      .peripheral
242      .subscribe(self.notification_characteristic(source))
243      .await?;
244    Ok(())
245  }
246
247  /// Disables a discovered notification characteristic.
248  async fn unsubscribe_source(&mut self, source: NotificationSource) -> Result<(), Error> {
249    trace_event!(source = ?source, "unsubscribing from BLE notifications");
250    self
251      .peripheral
252      .unsubscribe(self.notification_characteristic(source))
253      .await?;
254    Ok(())
255  }
256
257  /// Maps the protocol write kind and submits a command to the write characteristic.
258  async fn write(&mut self, command: &Command) -> Result<(), Error> {
259    trace_event!(
260      command_len = command.bytes().len(),
261      write_kind = ?command.write_kind(),
262      "writing command to BLE characteristic"
263    );
264    let write_type = match command.write_kind() {
265      WriteKind::WithResponse => WriteType::WithResponse,
266      WriteKind::WithoutResponse => WriteType::WithoutResponse,
267    };
268
269    self
270      .peripheral
271      .write(&self.command_write, command.bytes(), write_type)
272      .await?;
273    Ok(())
274  }
275
276  /// Copies and tags one value from the peripheral-wide notification stream.
277  async fn receive<'a>(&'a mut self, buffer: &'a mut [u8]) -> Result<Notification<'a>, Error> {
278    // btleplug exposes one stream for the peripheral. Tagging each value by
279    // UUID keeps the protocol decoder independent of btleplug types.
280    let notification = self
281      .notifications
282      .next()
283      .await
284      .ok_or(Error::NotificationStreamEnded)?;
285
286    let source =
287      if notification.uuid == Uuid::from_u128(Characteristic::PositionNotification.uuid_u128()) {
288        NotificationSource::Position
289      } else if notification.uuid == Uuid::from_u128(Characteristic::CommandResponse.uuid_u128()) {
290        NotificationSource::CommandResponse
291      } else {
292        warn_event!(
293          characteristic_uuid = %notification.uuid,
294          "received notification from an unexpected characteristic"
295        );
296        return Err(Error::UnexpectedNotificationCharacteristic(
297          notification.uuid,
298        ));
299      };
300
301    let actual = notification.value.len();
302    if actual > buffer.len() {
303      warn_event!(
304        notification_len = actual,
305        buffer_capacity = buffer.len(),
306        "notification exceeds receive buffer"
307      );
308      return Err(Error::NotificationTooLong {
309        actual,
310        capacity: buffer.len(),
311      });
312    }
313
314    buffer[..actual].copy_from_slice(&notification.value);
315    trace_event!(
316      source = ?source,
317      notification_len = actual,
318      "received BLE notification"
319    );
320    Ok(Notification::new(source, &buffer[..actual]))
321  }
322}
323
324impl<P: Peripheral> AsyncTransport for BtleplugTransport<P> {
325  type Error = Error;
326
327  async fn subscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
328    self.subscribe_source(source).await
329  }
330
331  async fn unsubscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
332    self.unsubscribe_source(source).await
333  }
334
335  async fn write_command(&mut self, command: &Command) -> Result<(), Self::Error> {
336    self.write(command).await
337  }
338
339  async fn next_notification<'a>(
340    &'a mut self,
341    buffer: &'a mut [u8],
342  ) -> Result<Notification<'a>, Self::Error> {
343    self.receive(buffer).await
344  }
345}
346
347#[cfg(feature = "tokio")]
348impl<P: Peripheral + 'static> TokioTransport for BtleplugTransport<P> {
349  type Error = Error;
350
351  async fn subscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
352    self.subscribe_source(source).await
353  }
354
355  async fn unsubscribe(&mut self, source: NotificationSource) -> Result<(), Self::Error> {
356    self.unsubscribe_source(source).await
357  }
358
359  async fn write_command(&mut self, command: &Command) -> Result<(), Self::Error> {
360    self.write(command).await
361  }
362
363  async fn next_notification<'a>(
364    &'a mut self,
365    buffer: &'a mut [u8],
366  ) -> Result<Notification<'a>, Self::Error> {
367    self.receive(buffer).await
368  }
369}