first commit
Some checks failed
Rust / Clippy (push) Has been cancelled
Rust / Test (nightly) (push) Has been cancelled
Rust / Test (stable) (push) Has been cancelled

This commit is contained in:
2026-01-04 16:50:19 +08:00
commit 6675986579
60 changed files with 11043 additions and 0 deletions

67
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Rust
on:
push:
branches:
- main
- dev
- feature/ci
pull_request:
branches:
- main
- dev
- feature/ci
env:
CARGO_TERM_COLOR: always
jobs:
lint:
name: Clippy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
run: |
sudo apt-get -y update
sudo apt-get -y install libdbus-1-dev protobuf-compiler
- name: Install rust
run: |
rustup update stable && rustup default stable
rustup component add clippy
- name: Run clippy
run: cargo clippy --all --all-features -- -Dwarnings
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
toolchain: [stable, nightly]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Install dependencies
run: |
sudo apt-get -y update
sudo apt-get -y install libdbus-1-dev protobuf-compiler
- name: Install rust
run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
- name: Build
run: |
cargo build --all --all-features
cargo build --all --all-features --examples
- name: Test
run: |
cargo test --all --all-features
cargo build --all --all-features --examples

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/.vscode
/target

2392
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[workspace]
resolver = "2"
members = [
"cli",
"libgfps",
"libmaestro",
"tui",
]

201
LICENSE-APACHE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Maximilian Luz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# `pbpctrl`
Control Google Pixel Buds Pro from the Linux command line. Might or might not work on other Pixel Buds devices.
Allows reading of battery, hardware, software, and runtime information as well as reading and changing settings (ANC state, equalizer, ...).
## Installation
### Arch Linux
A [`pbpctrl`](https://aur.archlinux.org/packages/pbpctrl) package is provided via the AUR.
Alternatively, the [`pbpctrl-git`](https://aur.archlinux.org/packages/pbpctrl-git) package can be used to directly build from the latest state on the `main` branch.
### Debian/Ubuntu
A [`pbpctrl`](https://mpr.makedeb.org/packages/pbpctrl) package is provided via the MPR.
### Installation via `cargo`
You will need to install the following dependencies:
- Ubuntu: `libdbus-1-dev pkg-config protobuf-compiler`
- Arch Linux: Please refer to the dependencies (`depends` and `makedepends` fields) in [this PKGBUILD](https://aur.archlinux.org/cgit/aur.git/tree/PKGBUILD?h=pbpctrl).
To build install the binary via cargo, run
```sh
cargo install pbpctrl --git https://github.com/qzed/pbpctrl/
```
Use the `--tag` option if you want to install a specific tag instead of the latest `main` branch.
## TUI Extension
A TUI wrapper `pbpctui` is provided in the `tui` directory. It uses `ratatui` to provide a visual interface for monitoring status and changing settings.
### Installation
To install the TUI from the source:
```sh
cargo install pbpctui --git https://github.com/qzed/pbpctrl/
```
### Usage
Ensure `pbpctrl` is installed and available in your PATH (or in the current directory).
Run:
```sh
pbpctui
```
Navigation:
- **Tab**: Switch between Status and Settings tabs.
- **Arrow Keys or j/k**: Navigate settings.
- **Enter**: Toggle/Cycle options.
- **Left/Right**: Adjust slider values (Balance, EQ).
- **q**: Quit.
## Instructions
Pair and connect your Pixel Buds Pro before use.
Run `pbpctrl help` for more information.
## Notes on Battery Information
The Pixel Buds Pro support basic battery information via the AVCPR standard.
Support for this is still experimental in BlueZ and needs to be enabled manually by editing `/etc/bluetooth/main.conf` and setting
```
[General]
Experimental = true
```
or by starting BlueZ with the `--experimental` flag.
After this, battery status should be provided via UPower.
Note that this, however, will only provide a single battery meter for both buds combined, and none for the case.
For more detailed information, use `pbpctrl show battery`.
This also allows reading of the case battery as long as one bud is placed in the case (note that the case does not have a Bluetooth receiver itself).
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0)
- MIT License ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

24
cli/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "pbpctrl"
authors = ["Maximilian Luz <m@mxnluz.io>"]
version = "0.1.8"
edition = "2024"
license = "MIT/Apache-2.0"
description = "Command-line utility for controlling Google Pixel Buds Pro"
repository = "https://github.com/qzed/pbpctrl"
[dependencies]
anyhow = "1.0.98"
bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] }
clap = { version = "4.5.37", features = ["derive"] }
futures = "0.3.31"
maestro = { path = "../libmaestro" }
tokio = { version = "1.44.2", features = ["rt", "macros", "signal"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
[build-dependencies]
bluer = { version = "0.17.4" }
clap = { version = "4.5.37", features = ["derive"] }
clap_complete = "4.5.47"
maestro = { path = "../libmaestro" }

21
cli/build.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::env;
use clap::CommandFactory;
use clap_complete::shells;
#[allow(dead_code)]
#[path = "src/cli.rs"]
mod cli;
use cli::*;
fn main() {
let outdir = env::var_os("CARGO_TARGET_DIR")
.or_else(|| env::var_os("OUT_DIR"))
.unwrap();
let mut cmd = Args::command();
clap_complete::generate_to(shells::Bash, &mut cmd, "pbpctrl", &outdir).unwrap();
clap_complete::generate_to(shells::Zsh, &mut cmd, "pbpctrl", &outdir).unwrap();
clap_complete::generate_to(shells::Fish, &mut cmd, "pbpctrl", &outdir).unwrap();
}

90
cli/src/bt.rs Normal file
View File

@@ -0,0 +1,90 @@
use std::time::Duration;
use anyhow::Result;
use bluer::{Adapter, Address, Device, Session};
use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile};
use futures::StreamExt;
const PIXEL_BUDS_CLASS: u32 = 0x240404;
const PIXEL_BUDS2_CLASS: u32 = 0x244404;
pub async fn find_maestro_device(adapter: &Adapter) -> Result<Device> {
for addr in adapter.device_addresses().await? {
let dev = adapter.device(addr)?;
let class = dev.class().await?.unwrap_or(0);
if class != PIXEL_BUDS_CLASS && class != PIXEL_BUDS2_CLASS {
continue;
}
let uuids = dev.uuids().await?.unwrap_or_default();
if !uuids.contains(&maestro::UUID) {
continue;
}
tracing::debug!(address=%addr, "found compatible device");
return Ok(dev);
}
tracing::debug!("no compatible device found");
anyhow::bail!("no compatible device found")
}
pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result<Stream> {
let maestro_profile = Profile {
uuid: maestro::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
tracing::debug!("registering maestro profile");
let mut handle = session.register_profile(maestro_profile).await?;
tracing::debug!("connecting to maestro profile");
let stream = tokio::try_join!(
try_connect_profile(dev),
handle_requests_for_profile(&mut handle, dev.address()),
)?.1;
Ok(stream)
}
async fn try_connect_profile(dev: &Device) -> Result<()> {
const RETRY_TIMEOUT: Duration = Duration::from_secs(1);
const MAX_TRIES: u32 = 3;
let mut i = 0;
while let Err(err) = dev.connect_profile(&maestro::UUID).await {
if i >= MAX_TRIES { return Err(err.into()) }
i += 1;
tracing::warn!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES);
tokio::time::sleep(RETRY_TIMEOUT).await;
}
tracing::debug!(address=%dev.address(), "maestro profile connected");
Ok(())
}
async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result<Stream> {
while let Some(req) = handle.next().await {
tracing::debug!(address=%req.device(), "received new profile connection request");
if req.device() == address {
tracing::debug!(address=%req.device(), "accepting profile connection request");
return Ok(req.accept()?);
} else {
req.reject(ReqError::Rejected);
}
}
anyhow::bail!("profile terminated without requests")
}

317
cli/src/cli.rs Normal file
View File

@@ -0,0 +1,317 @@
use bluer::Address;
use clap::{Parser, Subcommand, ValueEnum};
use maestro::service::settings;
/// Control Google Pixel Buds Pro from the command line
#[derive(Debug, Parser)]
#[command(author, version, about, long_about = None)]
pub struct Args {
/// Device to use (search for compatible device if unspecified)
#[arg(short, long, global=true)]
pub device: Option<Address>,
#[command(subcommand)]
pub command: Command
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Show device information
Show {
#[command(subcommand)]
command: ShowCommand
},
/// Read settings value
Get {
#[command(subcommand)]
setting: GetSetting
},
/// Write settings value
Set {
#[command(subcommand)]
setting: SetSetting
},
}
#[derive(Debug, Subcommand)]
pub enum ShowCommand {
/// Show software information.
Software,
/// Show hardware information.
Hardware,
/// Show runtime information.
Runtime,
/// Show battery status.
Battery,
}
#[derive(Debug, Subcommand)]
pub enum GetSetting {
/// Get automatic over-the-air update status
AutoOta,
/// Get on-head-detection state (enabled/disabled)
Ohd,
/// Get the flag indicating whether the out-of-box experience phase is
/// finished
OobeIsFinished,
/// Get gesture state (enabled/disabled)
Gestures,
/// Get diagnostics state (enabled/disabled)
Diagnostics,
/// Get out-of-box-experience mode state (enabled/disabled)
OobeMode,
/// Get hold-gesture action
GestureControl,
/// Get multipoint audio state (enabled/disabled)
Multipoint,
/// Get adaptive noise-cancelling gesture loop
AncGestureLoop,
/// Get adaptive noise-cancelling state
Anc,
/// Get volume-dependent EQ state (enabled/disabled)
VolumeEq,
/// Get 5-band EQ
Eq,
/// Get volume balance
Balance,
/// Get mono output state
Mono,
/// Get volume exposure notifications state (enabled/disabled)
VolumeExposureNotifications,
/// Get automatic transparency mode state (enabled/disabled)
SpeechDetection,
}
#[derive(Debug, Subcommand)]
pub enum SetSetting {
/// Enable/disable automatic over-the-air updates
///
/// Note: Updates are initiated by the Google Buds app on your phone. This
/// flag controls whether updates can be done automatically when the device
/// is not in use.
AutoOta {
/// Whether to enable or disable automatic over-the-air (OTA) updates
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable on-head detection
Ohd {
/// Whether to enable or disable on-head detection
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Set the flag indicating whether the out-of-box experience phase is
/// finished
///
/// Note: You normally do not want to change this flag. It is used to
/// indicate whether the out-of-box experience (OOBE) phase has been
/// concluded, i.e., the setup wizard has been run and the device has been
/// set up.
OobeIsFinished {
/// Whether the OOBE setup has been finished
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable gestures
Gestures {
/// Whether to enable or disable gestures
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable diagnostics
///
/// Note: This will also cause the Google Buds app on your phone to send
/// diagnostics data to Google.
Diagnostics {
/// Whether to enable or disable diagnostics
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable out-of-box-experience mode
///
/// Note: You normally do not want to enable this mode. It is used to
/// intercept and block touch gestures during the setup wizard.
OobeMode {
/// Whether to enable or disable the out-of-box experience mode
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Set hold-gesture action
GestureControl {
/// Left gesture action
#[arg(value_enum)]
left: HoldGestureAction,
/// Right gesture action
#[arg(value_enum)]
right: HoldGestureAction,
},
/// Enable/disable multipoint audio
Multipoint {
/// Whether to enable or disable multipoint audio
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Set adaptive noise-cancelling gesture loop
AncGestureLoop {
/// Enable 'off' mode in loop
#[arg(action=clap::ArgAction::Set)]
off: bool,
/// Enable 'active' mode in loop
#[arg(action=clap::ArgAction::Set)]
active: bool,
/// Enable 'aware' mode in loop
#[arg(action=clap::ArgAction::Set)]
aware: bool,
/// Enable 'adaptive' mode in loop
#[arg(action=clap::ArgAction::Set)]
adaptive: bool,
},
/// Set adaptive noise-cancelling state
Anc {
/// New ANC state or action to change state
#[arg(value_enum)]
value: AncState,
},
/// Enable/disable volume-dependent EQ
VolumeEq {
/// Whether to enable or disable volume-dependent EQ
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Set 5-band EQ
Eq {
/// Low-bass band (min: -6.0, max: 6.0)
#[arg(value_parser=parse_eq_value)]
low_bass: f32,
/// Bass band (min: -6.0, max: 6.0)
#[arg(value_parser=parse_eq_value)]
bass: f32,
/// Mid band (min: -6.0, max: 6.0)
#[arg(value_parser=parse_eq_value)]
mid: f32,
/// Treble band (min: -6.0, max: 6.0)
#[arg(value_parser=parse_eq_value)]
treble: f32,
/// Upper treble band (min: -6.0, max: 6.0)
#[arg(value_parser=parse_eq_value)]
upper_treble: f32,
},
/// Set volume balance
Balance {
/// Volume balance from -100 (left) to +100 (right)
#[arg(value_parser=parse_balance)]
value: i32,
},
/// Set mono output
Mono {
/// Whether to force mono output
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable volume level exposure notifications
VolumeExposureNotifications {
/// Whether to enable or disable volume level exposure notifications
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
/// Enable/disable automatic transparency mode via speech detection
SpeechDetection {
/// Whether to enable or disable the automatic transparency mode via speech detection
#[arg(action=clap::ArgAction::Set)]
value: bool,
},
}
#[derive(Debug, ValueEnum, Clone, Copy)]
pub enum AncState {
Off,
Active,
Aware,
Adaptive,
CycleNext,
CyclePrev,
}
#[derive(Debug, ValueEnum, Clone, Copy)]
pub enum HoldGestureAction {
Anc,
Assistant,
}
impl From<HoldGestureAction> for settings::RegularActionTarget {
fn from(value: HoldGestureAction) -> Self {
match value {
HoldGestureAction::Anc => settings::RegularActionTarget::AncControl,
HoldGestureAction::Assistant => settings::RegularActionTarget::AssistantQuery,
}
}
}
fn parse_eq_value(s: &str) -> std::result::Result<f32, String> {
let val = s.parse().map_err(|e| format!("{e}"))?;
if val > settings::EqBands::MAX_VALUE {
Err(format!("exceeds maximum of {}", settings::EqBands::MAX_VALUE))
} else if val < settings::EqBands::MIN_VALUE {
Err(format!("exceeds minimum of {}", settings::EqBands::MIN_VALUE))
} else {
Ok(val)
}
}
fn parse_balance(s: &str) -> std::result::Result<i32, String> {
let val = s.parse().map_err(|e| format!("{e}"))?;
if val > 100 {
Err("exceeds maximum of 100".to_string())
} else if val < -100 {
Err("exceeds minimum of -100".to_string())
} else {
Ok(val)
}
}

506
cli/src/main.rs Normal file
View File

@@ -0,0 +1,506 @@
mod bt;
mod cli;
use anyhow::Result;
use clap::{Parser, CommandFactory};
use futures::{Future, StreamExt};
use maestro::protocol::{utils, addr};
use maestro::pwrpc::client::{Client, ClientHandle};
use maestro::protocol::codec::Codec;
use maestro::service::MaestroService;
use maestro::service::settings::{self, Setting, SettingValue};
use cli::*;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();
// set up session
let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
// set up device
let dev = if let Some(address) = args.device {
tracing::debug!("using provided address: {}", address);
adapter.device(address)?
} else {
tracing::debug!("no device specified, searching for compatible one");
bt::find_maestro_device(&adapter).await?
};
// set up profile
let stream = bt::connect_maestro_rfcomm(&session, &dev).await?;
// set up codec
let codec = Codec::new();
let stream = codec.wrap(stream);
// set up RPC client
let mut client = Client::new(stream);
let handle = client.handle();
// resolve channel
let channel = utils::resolve_channel(&mut client).await?;
match args.command {
Command::Show { command } => match command {
ShowCommand::Software => run(client, cmd_show_software(handle, channel)).await,
ShowCommand::Hardware => run(client, cmd_show_hardware(handle, channel)).await,
ShowCommand::Runtime => run(client, cmd_show_runtime(handle, channel)).await,
ShowCommand::Battery => run(client, cmd_show_battery(handle, channel)).await,
},
Command::Get { setting } => match setting {
GetSetting::AutoOta => {
run(client, cmd_get_setting(handle, channel, settings::id::AutoOtaEnable)).await
},
GetSetting::Ohd => {
run(client, cmd_get_setting(handle, channel, settings::id::OhdEnable)).await
},
GetSetting::OobeIsFinished => {
run(client, cmd_get_setting(handle, channel, settings::id::OobeIsFinished)).await
},
GetSetting::Gestures => {
run(client, cmd_get_setting(handle, channel, settings::id::GestureEnable)).await
},
GetSetting::Diagnostics => {
run(client, cmd_get_setting(handle, channel, settings::id::DiagnosticsEnable)).await
}
GetSetting::OobeMode => {
run(client, cmd_get_setting(handle, channel, settings::id::OobeMode)).await
},
GetSetting::GestureControl => {
run(client, cmd_get_setting(handle, channel, settings::id::GestureControl)).await
},
GetSetting::Multipoint => {
run(client, cmd_get_setting(handle, channel, settings::id::MultipointEnable)).await
},
GetSetting::AncGestureLoop => {
run(client, cmd_get_setting(handle, channel, settings::id::AncrGestureLoop)).await
}
GetSetting::Anc => {
run(client, cmd_get_setting(handle, channel, settings::id::CurrentAncrState)).await
},
GetSetting::VolumeEq => {
run(client, cmd_get_setting(handle, channel, settings::id::VolumeEqEnable)).await
},
GetSetting::Eq => {
run(client, cmd_get_setting(handle, channel, settings::id::CurrentUserEq)).await
},
GetSetting::Balance => {
run(client, cmd_get_setting(handle, channel, settings::id::VolumeAsymmetry)).await
},
GetSetting::Mono => {
run(client, cmd_get_setting(handle, channel, settings::id::SumToMono)).await
},
GetSetting::VolumeExposureNotifications => {
run(client, cmd_get_setting(handle, channel, settings::id::VolumeExposureNotifications)).await
},
GetSetting::SpeechDetection => {
run(client, cmd_get_setting(handle, channel, settings::id::SpeechDetection)).await
},
},
Command::Set { setting } => match setting {
SetSetting::AutoOta { value } => {
let value = SettingValue::AutoOtaEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Ohd { value } => {
let value = SettingValue::OhdEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::OobeIsFinished { value } => {
let value = SettingValue::OobeIsFinished(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Gestures { value } => {
let value = SettingValue::GestureEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Diagnostics { value } => {
let value = SettingValue::DiagnosticsEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::OobeMode { value } => {
let value = SettingValue::OobeMode(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::GestureControl { left, right } => {
let value = settings::GestureControl { left: left.into(), right: right.into() };
let value = SettingValue::GestureControl(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Multipoint { value } => {
let value = SettingValue::MultipointEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::AncGestureLoop { off, active, aware, adaptive } => {
let value = settings::AncrGestureLoop { off, active, aware, adaptive };
if !value.is_valid() {
use clap::error::ErrorKind;
let mut cmd = Args::command();
let err = cmd.error(
ErrorKind::InvalidValue,
"This command requires at least tow enabled ('true') modes"
);
err.exit();
}
let value = SettingValue::AncrGestureLoop(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Anc { value } => {
match value {
AncState::Off => {
let value = SettingValue::CurrentAncrState(settings::AncState::Off);
run(client, cmd_set_setting(handle, channel, value)).await
},
AncState::Aware => {
let value = SettingValue::CurrentAncrState(settings::AncState::Aware);
run(client, cmd_set_setting(handle, channel, value)).await
},
AncState::Active => {
let value = SettingValue::CurrentAncrState(settings::AncState::Active);
run(client, cmd_set_setting(handle, channel, value)).await
},
AncState::Adaptive => {
let value = SettingValue::CurrentAncrState(settings::AncState::Adaptive);
run(client, cmd_set_setting(handle, channel, value)).await
},
AncState::CycleNext => {
run(client, cmd_anc_cycle(handle, channel, true)).await
},
AncState::CyclePrev => {
run(client, cmd_anc_cycle(handle, channel, false)).await
},
}
},
SetSetting::VolumeEq { value } => {
let value = SettingValue::VolumeEqEnable(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Eq { low_bass, bass, mid, treble, upper_treble } => {
let value = settings::EqBands::new(low_bass, bass, mid, treble, upper_treble);
let value = SettingValue::CurrentUserEq(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Balance { value } => {
let value = settings::VolumeAsymmetry::from_normalized(value);
let value = SettingValue::VolumeAsymmetry(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::Mono { value } => {
let value = SettingValue::SumToMono(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::VolumeExposureNotifications { value } => {
let value = SettingValue::VolumeExposureNotifications(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
SetSetting::SpeechDetection { value } => {
let value = SettingValue::SpeechDetection(value);
run(client, cmd_set_setting(handle, channel, value)).await
},
},
}
}
async fn cmd_show_software(handle: ClientHandle, channel: u32) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
let info = service.get_software_info().await?;
let fw_ver_case = info.firmware.as_ref()
.and_then(|fw| fw.case.as_ref())
.map(|fw| fw.version_string.as_str())
.unwrap_or("unknown");
let fw_ver_left = info.firmware.as_ref()
.and_then(|fw| fw.left.as_ref())
.map(|fw| fw.version_string.as_str())
.unwrap_or("unknown");
let fw_ver_right = info.firmware.as_ref()
.and_then(|fw| fw.right.as_ref())
.map(|fw| fw.version_string.as_str())
.unwrap_or("unknown");
let fw_unk_case = info.firmware.as_ref()
.and_then(|fw| fw.case.as_ref())
.map(|fw| fw.unknown.as_str())
.unwrap_or("unknown");
let fw_unk_left = info.firmware.as_ref()
.and_then(|fw| fw.left.as_ref())
.map(|fw| fw.unknown.as_str())
.unwrap_or("unknown");
let fw_unk_right = info.firmware.as_ref()
.and_then(|fw| fw.right.as_ref())
.map(|fw| fw.unknown.as_str())
.unwrap_or("unknown");
println!("firmware:");
println!(" case: {fw_ver_case} ({fw_unk_case})");
println!(" left bud: {fw_ver_left} ({fw_unk_left})");
println!(" right bud: {fw_ver_right} ({fw_unk_right})");
Ok(())
}
async fn cmd_show_hardware(handle: ClientHandle, channel: u32) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
let info = service.get_hardware_info().await?;
let serial_case = info.serial_number.as_ref()
.map(|ser| ser.case.as_str())
.unwrap_or("unknown");
let serial_left = info.serial_number.as_ref()
.map(|ser| ser.left.as_str())
.unwrap_or("unknown");
let serial_right = info.serial_number.as_ref()
.map(|ser| ser.right.as_str())
.unwrap_or("unknown");
println!("serial numbers:");
println!(" case: {serial_case}");
println!(" left bud: {serial_left}");
println!(" right bud: {serial_right}");
Ok(())
}
async fn cmd_show_runtime(handle: ClientHandle, channel: u32) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
let mut call = service.subscribe_to_runtime_info()?;
let info = call.stream().next().await
.ok_or_else(|| anyhow::anyhow!("stream terminated without item"))??;
let bat_level_case = info.battery_info.as_ref()
.and_then(|b| b.case.as_ref())
.map(|b| b.level);
let bat_state_case = info.battery_info.as_ref()
.and_then(|b| b.case.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
let bat_level_left = info.battery_info.as_ref()
.and_then(|b| b.left.as_ref())
.map(|b| b.level);
let bat_state_left = info.battery_info.as_ref()
.and_then(|b| b.left.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
let bat_level_right = info.battery_info.as_ref()
.and_then(|b| b.right.as_ref())
.map(|b| b.level);
let bat_state_right = info.battery_info.as_ref()
.and_then(|b| b.right.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
let place_left = info.placement.as_ref()
.map(|p| if p.left_bud_in_case { "in case" } else { "out of case" })
.unwrap_or("unknown");
let place_right = info.placement.as_ref()
.map(|p| if p.right_bud_in_case { "in case" } else { "out of case" })
.unwrap_or("unknown");
println!("clock: {} ms", info.timestamp_ms);
println!();
println!("battery:");
if let Some(lvl) = bat_level_case {
println!(" case: {lvl}% ({bat_state_case})");
} else {
println!(" case: unknown");
}
if let Some(lvl) = bat_level_left {
println!(" left bud: {lvl}% ({bat_state_left})");
} else {
println!(" left bud: unknown");
}
if let Some(lvl) = bat_level_right {
println!(" right bud: {lvl}% ({bat_state_right})");
} else {
println!(" right bud: unknown");
}
println!();
println!("placement:");
println!(" left bud: {place_left}");
println!(" right bud: {place_right}");
let address = addr::address_for_channel(channel);
let peer_local = address.map(|a| a.source());
let peer_remote = address.map(|a| a.target());
println!();
println!("connection:");
if let Some(peer) = peer_local {
println!(" local: {peer:?}");
} else {
println!(" local: unknown");
}
if let Some(peer) = peer_remote {
println!(" remote: {peer:?}");
} else {
println!(" remote: unknown");
}
Ok(())
}
async fn cmd_show_battery(handle: ClientHandle, channel: u32) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
let mut call = service.subscribe_to_runtime_info()?;
let info = call.stream().next().await
.ok_or_else(|| anyhow::anyhow!("stream terminated without item"))??;
let bat_level_case = info.battery_info.as_ref()
.and_then(|b| b.case.as_ref())
.map(|b| b.level);
let bat_state_case = info.battery_info.as_ref()
.and_then(|b| b.case.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
let bat_level_left = info.battery_info.as_ref()
.and_then(|b| b.left.as_ref())
.map(|b| b.level);
let bat_state_left = info.battery_info.as_ref()
.and_then(|b| b.left.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
let bat_level_right = info.battery_info.as_ref()
.and_then(|b| b.right.as_ref())
.map(|b| b.level);
let bat_state_right = info.battery_info.as_ref()
.and_then(|b| b.right.as_ref())
.map(|b| if b.state == 2 { "charging" } else if b.state == 1 { "not charging" } else { "unknown" })
.unwrap_or("unknown");
if let Some(lvl) = bat_level_case {
println!("case: {lvl}% ({bat_state_case})");
} else {
println!("case: unknown");
}
if let Some(lvl) = bat_level_left {
println!("left bud: {lvl}% ({bat_state_left})");
} else {
println!("left bud: unknown");
}
if let Some(lvl) = bat_level_right {
println!("right bud: {lvl}% ({bat_state_right})");
} else {
println!("right bud: unknown");
}
Ok(())
}
async fn cmd_get_setting<T>(handle: ClientHandle, channel: u32, setting: T) -> Result<()>
where
T: Setting,
T::Type: std::fmt::Display,
{
let mut service = MaestroService::new(handle, channel);
let value = service.read_setting(setting).await?;
println!("{value}");
Ok(())
}
async fn cmd_set_setting(handle: ClientHandle, channel: u32, setting: SettingValue) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
service.write_setting(setting).await?;
Ok(())
}
async fn cmd_anc_cycle(handle: ClientHandle, channel: u32, forward: bool) -> Result<()> {
let mut service = MaestroService::new(handle, channel);
let enabled = service.read_setting(settings::id::AncrGestureLoop).await?;
let state = service.read_setting(settings::id::CurrentAncrState).await?;
if let settings::AncState::Unknown(x) = state {
anyhow::bail!("unknown ANC state: {x}");
}
let states = [
(settings::AncState::Active, enabled.active),
(settings::AncState::Off, enabled.off),
(settings::AncState::Aware, enabled.aware),
];
let index = states.iter().position(|(s, _)| *s == state).unwrap();
for offs in 1..states.len() {
let next = if forward {
index + offs
} else {
index + states.len() - offs
} % states.len();
let (state, enabled) = states[next];
if enabled {
service.write_setting(SettingValue::CurrentAncrState(state)).await?;
break;
}
}
Ok(())
}
pub async fn run<S, E, F>(mut client: Client<S>, task: F) -> Result<()>
where
S: futures::Sink<maestro::pwrpc::types::RpcPacket>,
S: futures::Stream<Item = Result<maestro::pwrpc::types::RpcPacket, E>> + Unpin,
maestro::pwrpc::Error: From<E>,
maestro::pwrpc::Error: From<S::Error>,
F: Future<Output=Result<(), anyhow::Error>>,
{
tokio::select! {
res = client.run() => {
res?;
anyhow::bail!("client terminated unexpectedly");
},
res = task => {
res?;
tracing::trace!("task terminated successfully");
}
sig = tokio::signal::ctrl_c() => {
sig?;
tracing::trace!("client termination requested");
},
}
client.terminate().await?;
tracing::trace!("client terminated successfully");
Ok(())
}

35
docs/Notes.md Normal file
View File

@@ -0,0 +1,35 @@
# Notes
The Google Pixel Buds Pro rely on at least two different protocols apart from the standard audio profiles (HSP/HFP, A2DP, AVRCP):
- The Google Fast Pair Service (GFPS) protocol provides support for somewhat standardized events and actions (next to fast-pairing as advertised in its name).
This includes battery status of the individual parts (left/right buds and case), multi-point audio source switching notifications, ringing for find-my-device actions, etc.
See https://developers.google.com/nearby/fast-pair for details.
- The proprietary "Maestro" protocol is used to change settings on the buds (noise-cancelling, equalizer, balance, ...) and likely also update the firmware.
Note that while AVRCP can provide battery information, this only seems to be a single value for both buds combined and does not include the case.
Detailed battery information can only be obtained via the GFPS protocol.
## Google Fast Pair Service Protocol
See https://developers.google.com/nearby/fast-pair for a somewhat limited specification.
Unfortunately this is incomplete.
More details can be found in the Android source code, e.g. [here][gfps-android-0] and [here][gfps-android-1].
The Pixel Buds Pro, however, send additional messages with group and code numbers beyond the ones mentioned there.
The main part of this protocol is a [RFCOMM channel][gfps-rfcomm] which provides events, including battery notifications.
On the Pixel Buds Pro, this also seems to include events for changes to the ANC status (group 8, code 19).
[gfps-android-0]: https://cs.android.com/android/platform/superproject/+/master:out/soong/.intermediates/packages/modules/Connectivity/nearby/tests/multidevices/clients/test_support/fastpair_provider/proto/NearbyFastPairProviderLiteProtos/android_common/xref/srcjars.xref/android/nearby/fastpair/provider/EventStreamProtocol.java;drc=cb3bd7c37d630acb613e10f730c532128a02a3d5;l=69
[gfps-android-1]: https://cs.android.com/android/platform/superproject/+/master:packages/modules/Connectivity/nearby/tests/multidevices/clients/test_support/fastpair_provider/src/android/nearby/fastpair/provider/FastPairSimulator.java;l=1199;drc=cb3bd7c37d630acb613e10f730c532128a02a3d5?q=df21fe2c-2515-4fdb-8886-f12c4d67927c&ss=android%2Fplatform%2Fsuperproject
[gfps-rfcomm]: https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream
## Maestro Protocol
The "Maestro" protocol is a proprietary protocol for changing settings, used on the Pixel Buds Pro.
It's possible that this is targeted more generally at Google wearable devices.
The protocol not only allows for changing settings or getting hardware/firmware information, but also allows for subscribing to events, such as settings changes.
The protocol is implemented using the [pigweed RPC library](https://pigweed.dev/pw_rpc/), which is similar to [gRPC](https://grpc.io/) and relies on [protocol buffers](https://developers.google.com/protocol-buffers) for message encoding.
In addition, the RPC messages are wrapped in High-Level Data Link Control (HDLC) U-frames (an example for this is given [here](https://pigweed.dev/pw_hdlc/rpc_example/#module-pw-hdlc-rpc-example)).

22
libgfps/Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "gfps"
authors = ["Maximilian Luz <m@mxnluz.io>"]
version = "0.1.3"
edition = "2024"
license = "MIT/Apache-2.0"
description = "Google Fast Pair Service (GFPS) protocol client library"
repository = "https://github.com/qzed/pbpctrl"
[dependencies]
bytes = "1.10.1"
num_enum = "0.7.3"
smallvec = { version = "1.15.0", features = ["union"] }
tokio = "1.44.2"
tokio-util = { version = "0.7.14", features = ["codec"] }
uuid = "1.16.0"
[dev-dependencies]
bluer = { version = "0.17.3", features = ["bluetoothd", "rfcomm"] }
futures = "0.3.31"
pretty-hex = "0.4.1"
tokio = { version = "1.44.2", features = ["rt", "macros"] }

View File

@@ -0,0 +1,155 @@
//! Simple example for receiving battery info via the GFPS RFCOMM channel.
//!
//! Usage:
//! cargo run --example gfps_get_battery -- <bluetooth-device-address>
use std::str::FromStr;
use bluer::{Address, Session, Device};
use bluer::rfcomm::{Profile, ReqError, Role, ProfileHandle};
use futures::StreamExt;
use gfps::msg::{Codec, DeviceEventCode, EventGroup, BatteryInfo};
use num_enum::FromPrimitive;
#[tokio::main(flavor = "current_thread")]
async fn main() -> bluer::Result<()> {
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
// get device
let dev = adapter.device(addr)?;
// get RFCOMM stream
let stream = {
// register GFPS profile
let profile = Profile {
uuid: gfps::msg::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
let mut profile_handle = session.register_profile(profile).await?;
// connect profile
connect_device_to_profile(&mut profile_handle, &dev).await?
};
// listen to event messages
let codec = Codec::new();
let mut stream = codec.wrap(stream);
// The battery status cannot be queried via a normal command. However, it
// is sent right after we connect to the GFPS stream. In addition, multiple
// events are often sent in sequence. Therefore we do the following:
// - Set a deadline for a general timeout. If this passes, we just return
// the current state (and if necessary "unknown"):
// - Use a timestamp for checking whether we have received any new updates
// in a given interval. If we have not received any, we consider the
// state to be "settled" and return the battery info.
// - On battery events we simply store the sent information. We retreive
// the stored information once either of the timeouts kicks in.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut timestamp = deadline;
let mut bat_left = BatteryInfo::Unknown;
let mut bat_right = BatteryInfo::Unknown;
let mut bat_case = BatteryInfo::Unknown;
let time_settle = std::time::Duration::from_millis(500);
loop {
tokio::select! {
// receive and handle events
msg = stream.next() => {
match msg {
Some(Ok(msg)) => {
let group = EventGroup::from_primitive(msg.group);
if group != EventGroup::Device {
continue;
}
let code = DeviceEventCode::from_primitive(msg.code);
if code == DeviceEventCode::BatteryInfo {
timestamp = std::time::Instant::now();
bat_left = BatteryInfo::from_byte(msg.data[0]);
bat_right = BatteryInfo::from_byte(msg.data[1]);
bat_case = BatteryInfo::from_byte(msg.data[2]);
}
},
Some(Err(err)) => {
Err(err)?;
},
None => {
let err = std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"connection closed"
);
Err(err)?;
}
}
},
// timeout for determining when the state has "settled"
_ = tokio::time::sleep(tokio::time::Duration::from_millis(time_settle.as_millis() as _)) => {
let delta = std::time::Instant::now() - timestamp;
if delta > time_settle {
break
}
},
// general deadline
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {
break
},
}
}
println!("Battery status:");
println!(" left bud: {}", bat_left);
println!(" right bud: {}", bat_right);
println!(" case: {}", bat_case);
Ok(())
}
async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device)
-> bluer::Result<bluer::rfcomm::Stream>
{
loop {
tokio::select! {
res = async {
let _ = dev.connect().await;
dev.connect_profile(&gfps::msg::UUID).await
} => {
if let Err(err) = res {
println!("Connecting GFPS profile failed: {:?}", err);
}
tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
},
req = profile.next() => {
let req = req.expect("no connection request received");
if req.device() == dev.address() {
// accept our device
break req.accept();
} else {
// reject unknown devices
req.reject(ReqError::Rejected);
}
},
}
}
}

View File

@@ -0,0 +1,419 @@
//! Simple example for listening to GFPS messages sent via the RFCOMM channel.
//!
//! Usage:
//! cargo run --example gfps_listen -- <bluetooth-device-address>
use std::str::FromStr;
use bluer::{Address, Session, Device};
use bluer::rfcomm::{Profile, ReqError, Role, ProfileHandle};
use futures::StreamExt;
use gfps::msg::{
AcknowledgementEventCode, Codec, DeviceActionEventCode, DeviceCapabilitySyncEventCode,
DeviceConfigurationEventCode, DeviceEventCode, EventGroup, Message, PlatformType,
SassEventCode, LoggingEventCode, BluetoothEventCode, BatteryInfo,
};
use num_enum::FromPrimitive;
#[tokio::main(flavor = "current_thread")]
async fn main() -> bluer::Result<()> {
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
println!("Using adapter '{}'", adapter.name());
// get device
let dev = adapter.device(addr)?;
let uuids = {
let mut uuids = Vec::from_iter(dev.uuids().await?
.unwrap_or_default()
.into_iter());
uuids.sort_unstable();
uuids
};
println!("Found device:");
println!(" alias: {}", dev.alias().await?);
println!(" address: {}", dev.address());
println!(" paired: {}", dev.is_paired().await?);
println!(" connected: {}", dev.is_connected().await?);
println!(" UUIDs:");
for uuid in uuids {
println!(" {}", uuid);
}
println!();
// try to reconnect if connection is reset
loop {
let stream = {
// register GFPS profile
println!("Registering GFPS profile...");
let profile = Profile {
uuid: gfps::msg::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
let mut profile_handle = session.register_profile(profile).await?;
// connect profile
println!("Connecting GFPS profile...");
connect_device_to_profile(&mut profile_handle, &dev).await?
};
println!("Profile connected");
// listen to event messages
let codec = Codec::new();
let mut stream = codec.wrap(stream);
println!("Listening...");
println!();
while let Some(msg) = stream.next().await {
match msg {
Ok(msg) => {
print_message(&msg);
}
Err(e) if e.raw_os_error() == Some(104) => {
// The Pixel Buds Pro can hand off processing between each
// other. On a switch, the connection is reset. Wait a bit
// and then try to reconnect.
println!();
println!("Connection reset. Attempting to reconnect...");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
break;
}
Err(e) => {
Err(e)?;
}
}
}
}
}
async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device)
-> bluer::Result<bluer::rfcomm::Stream>
{
loop {
tokio::select! {
res = async {
let _ = dev.connect().await;
dev.connect_profile(&gfps::msg::UUID).await
} => {
if let Err(err) = res {
println!("Connecting GFPS profile failed: {:?}", err);
}
tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
},
req = profile.next() => {
let req = req.expect("no connection request received");
if req.device() == dev.address() {
println!("Accepting request...");
break req.accept();
} else {
println!("Rejecting unknown device {}", req.device());
req.reject(ReqError::Rejected);
}
},
}
}
}
fn print_message(msg: &Message) {
let group = EventGroup::from_primitive(msg.group);
match group {
EventGroup::Bluetooth => {
let code = BluetoothEventCode::from_primitive(msg.code);
println!("Bluetooth (0x{:02X}) :: ", msg.group);
match code {
BluetoothEventCode::EnableSilenceMode => {
println!("Enable Silence Mode (0x{:02X})", msg.code);
},
BluetoothEventCode::DisableSilenceMode => {
println!("Disable Silence Mode (0x{:02X})", msg.code);
},
_ => {
println!("Unknown (0x{:02X})", msg.code);
},
}
print_message_body_unknown(msg);
println!();
}
EventGroup::Logging => {
let code = LoggingEventCode::from_primitive(msg.code);
println!("Companion App (0x{:02X}) :: ", msg.group);
match code {
LoggingEventCode::LogFull => {
println!("Log Full (0x{:02X})", msg.code);
}
LoggingEventCode::LogSaveToBuffer => {
println!("Log Save Buffer (0x{:02X})", msg.code);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
}
}
print_message_body_unknown(msg);
println!();
}
EventGroup::Device => {
let code = DeviceEventCode::from_primitive(msg.code);
print!("Device Information (0x{:02X}) :: ", msg.group);
match code {
DeviceEventCode::ModelId => {
println!("Model Id (0x{:02X})", msg.code);
println!(" model: {:02X}{:02X}{:02X}", msg.data[0], msg.data[1], msg.data[2]);
}
DeviceEventCode::BleAddress => {
println!("BLE Address (0x{:02X})", msg.code);
println!(" address: {}", Address::new(msg.data[0..6].try_into().unwrap()));
}
DeviceEventCode::BatteryInfo => {
println!("Battery Info (0x{:02X})", msg.code);
let left = BatteryInfo::from_byte(msg.data[0]);
let right = BatteryInfo::from_byte(msg.data[1]);
let case = BatteryInfo::from_byte(msg.data[2]);
println!(" left bud: {}", left);
println!(" right bud: {}", right);
println!(" case: {}", case);
}
DeviceEventCode::BatteryTime => {
println!("Remaining Battery Time (0x{:02X})", msg.code);
let time = match msg.data.len() {
1 => msg.data[0] as u16,
2 => u16::from_be_bytes(msg.data[0..2].try_into().unwrap()),
_ => panic!("invalid format"),
};
println!(" time: {} minutes", time);
}
DeviceEventCode::ActiveComponentsRequest => {
println!("Active Components Request (0x{:02X})", msg.code);
}
DeviceEventCode::ActiveComponentsResponse => {
println!("Active Components Response (0x{:02X})", msg.code);
println!(" components: {:08b}", msg.data[0]);
}
DeviceEventCode::Capability => {
println!("Capability (0x{:02X})", msg.code);
println!(" capabilities: {:08b}", msg.data[0]);
}
DeviceEventCode::PlatformType => {
println!("Platform Type (0x{:02X})", msg.code);
let platform = PlatformType::from_primitive(msg.data[0]);
match platform {
PlatformType::Android => {
println!(" platform: Android (0x{:02X})", msg.data[0]);
println!(" SDK version: {:02X?})", msg.data[1]);
}
_ => {
println!(" platform: Unknown (0x{:02X})", msg.data[0]);
println!(" platform data: 0x{:02X?})", msg.data[1]);
}
}
}
DeviceEventCode::FirmwareVersion => {
println!("Firmware Version (0x{:02X})", msg.code);
if let Ok(ver) = std::str::from_utf8(&msg.data) {
println!(" version: {:?}", ver);
} else {
println!(" version: {:02X?}", msg.data);
}
}
DeviceEventCode::SectionNonce => {
println!("Session Nonce (0x{:02X})", msg.code);
println!(" nonce: {:02X?}", msg.data);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
print_message_body_unknown(msg);
}
}
println!();
}
EventGroup::DeviceAction => {
let code = DeviceActionEventCode::from_primitive(msg.code);
print!("Device Action (0x{:02X}) :: ", msg.group);
match code {
DeviceActionEventCode::Ring => {
println!("Ring (0x{:02X})", msg.code);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
}
}
print_message_body_unknown(msg);
println!();
}
EventGroup::DeviceConfiguration => {
let code = DeviceConfigurationEventCode::from_primitive(msg.code);
print!("Device Configuration (0x{:02X}) :: ", msg.group);
match code {
DeviceConfigurationEventCode::BufferSize => {
println!("Buffer Size (0x{:02X})", msg.code);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
}
}
print_message_body_unknown(msg);
println!();
}
EventGroup::DeviceCapabilitySync => {
let code = DeviceCapabilitySyncEventCode::from_primitive(msg.code);
print!("Device Cpabilities Sync (0x{:02X}) :: ", msg.group);
match code {
DeviceCapabilitySyncEventCode::CapabilityUpdate => {
println!("Capability Update (0x{:02X})", msg.code);
}
DeviceCapabilitySyncEventCode::ConfigurableBufferSizeRange => {
println!("Configurable Buffer Size Range (0x{:02X})", msg.code);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
}
}
print_message_body_unknown(msg);
println!();
}
EventGroup::SmartAudioSourceSwitching => {
let code = SassEventCode::from_primitive(msg.code);
print!("Smart Audio Source Switching (0x{:02X}) :: ", msg.group);
match code {
SassEventCode::GetCapabilityOfSass => {
println!("Get Capability (0x{:02X})", msg.code);
}
SassEventCode::NotifyCapabilityOfSass => {
println!("Notify Capability (0x{:02X})", msg.code);
}
SassEventCode::SetMultiPointState => {
println!("Set Multi-Point State (0x{:02X})", msg.code);
}
SassEventCode::SwitchAudioSourceBetweenConnectedDevices => {
println!("Switch Audio Source Between Connected Devices (0x{:02X})", msg.code);
}
SassEventCode::SwitchBack => {
println!("Switch Back (0x{:02X})", msg.code);
}
SassEventCode::NotifyMultiPointSwitchEvent => {
println!("Notify Multi-Point (0x{:02X})", msg.code);
}
SassEventCode::GetConnectionStatus => {
println!("Get Connection Status (0x{:02X})", msg.code);
}
SassEventCode::NotifyConnectionStatus => {
println!("Notify Connection Status (0x{:02X})", msg.code);
}
SassEventCode::SassInitiatedConnection => {
println!("SASS Initiated Connection (0x{:02X})", msg.code);
}
SassEventCode::IndicateInUseAccountKey => {
println!("Indicate In-Use Account Key (0x{:02X})", msg.code);
}
SassEventCode::SetCustomData => {
println!("Set Custom Data (0x{:02X})", msg.code);
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
}
}
print_message_body_unknown(msg);
println!();
}
EventGroup::Acknowledgement => {
let code = AcknowledgementEventCode::from_primitive(msg.code);
print!("Acknowledgement (0x{:02X}) ::", msg.group);
match code {
AcknowledgementEventCode::Ack => {
println!("ACK (0x{:02X})", msg.code);
println!(" group: 0x{:02X}", msg.data[0]);
println!(" code: 0x{:02X}", msg.data[1]);
println!();
}
AcknowledgementEventCode::Nak => {
println!("NAK (0x{:02X})", msg.code);
match msg.data[0] {
0x00 => println!(" reason: Not supported (0x00)"),
0x01 => println!(" reason: Device busy (0x01)"),
0x02 => println!(" reason: Not allowed due to current state (0x02)"),
_ => println!(" reason: Unknown (0x{:02X})", msg.data[0]),
}
println!(" group: 0x{:02X}", msg.data[1]);
println!(" code: 0x{:02X}", msg.data[2]);
println!();
}
_ => {
println!("Unknown (0x{:02X})", msg.code);
print_message_body_unknown(msg);
println!();
}
}
}
_ => {
println!(
"Unknown (0x{:02X}) :: Unknown (0x{:02X})",
msg.group, msg.code
);
print_message_body_unknown(msg);
println!();
}
}
}
fn print_message_body_unknown(msg: &Message) {
let data = pretty_hex::config_hex(
&msg.data,
pretty_hex::HexConfig {
title: false,
..Default::default()
},
);
for line in data.lines() {
println!(" {}", line);
}
}

241
libgfps/examples/ring.rs Normal file
View File

@@ -0,0 +1,241 @@
//! Simple example for "ringing" the buds to locate them.
//!
//! WARNING: DO NOT RUN THIS EXAMPLE WITH THE BUDS IN YOUR EAR! YOU HAVE BEEN WARNED.
//!
//! Usage:
//! cargo run --example ring -- <bluetooth-device-address>
use std::str::FromStr;
use bluer::{Address, Session, Device};
use bluer::rfcomm::{Profile, Role, ProfileHandle, ReqError};
use futures::{StreamExt, SinkExt};
use gfps::msg::{Codec, Message, EventGroup, DeviceActionEventCode, AcknowledgementEventCode};
use num_enum::FromPrimitive;
use smallvec::smallvec;
#[tokio::main(flavor = "current_thread")]
async fn main() -> bluer::Result<()> {
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
// get device
let dev = adapter.device(addr)?;
// get RFCOMM stream
let stream = {
// register GFPS profile
let profile = Profile {
uuid: gfps::msg::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
let mut profile_handle = session.register_profile(profile).await?;
// connect profile
connect_device_to_profile(&mut profile_handle, &dev).await?
};
// set up message stream
let codec = Codec::new();
let mut stream = codec.wrap(stream);
// send "ring" message
//
// Note: Pixel Buds Pro ignore messages with a timeout. So don't specify
// one here.
let msg = Message {
group: EventGroup::DeviceAction.into(),
code: DeviceActionEventCode::Ring.into(),
data: smallvec![0x03], // 0b01: right, 0b10: left, 0b10|0b01 = 0b11: both
};
println!("Ringing buds...");
stream.send(&msg).await?;
// An ACK message should come in 1s. Wait for that.
let timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(1);
loop {
tokio::select! {
msg = stream.next() => {
match msg {
Some(Ok(msg)) => {
println!("{:?}", msg);
let group = EventGroup::from_primitive(msg.group);
if group != EventGroup::Acknowledgement {
continue;
}
let ack_group = EventGroup::from_primitive(msg.data[0]);
if ack_group != EventGroup::DeviceAction {
continue;
}
let ack_code = DeviceActionEventCode::from_primitive(msg.data[1]);
if ack_code != DeviceActionEventCode::Ring {
continue;
}
let code = AcknowledgementEventCode::from_primitive(msg.code);
if code == AcknowledgementEventCode::Ack {
println!("Received ACK for ring command");
break;
} else if code == AcknowledgementEventCode::Nak {
println!("Received NAK for ring command");
let err = std::io::Error::new(
std::io::ErrorKind::Unsupported,
"ring has been NAK'ed by device"
);
Err(err)?;
}
},
Some(Err(e)) => {
Err(e)?;
},
None => {
let err = std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"connection closed"
);
Err(err)?;
}
}
},
_ = tokio::time::sleep_until(timeout) => {
let err = std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out, ring action might be unsupported"
);
Err(err)?;
},
}
}
// Next, the device will communicate back status updates. This may include
// an initial update to confirm ringing and follow-up updates once the user
// has touched the buds and ringing stops.
//
// Stop this program once we have no more rining or once we have reached a
// timeout of 30s.
let mut timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
loop {
tokio::select! {
msg = stream.next() => {
match msg {
Some(Ok(msg)) => {
println!("{:?}", msg);
let group = EventGroup::from_primitive(msg.group);
if group != EventGroup::DeviceAction {
continue;
}
// send ACK
let ack = Message {
group: EventGroup::Acknowledgement.into(),
code: AcknowledgementEventCode::Ack.into(),
data: smallvec![msg.group, msg.code],
};
stream.send(&ack).await?;
let status = msg.data[0];
println!("Received ring update:");
if status & 0b01 != 0 {
println!(" right: ringing");
} else {
println!(" right: not ringing");
}
if status & 0b10 != 0 {
println!(" left: ringing");
} else {
println!(" left: not ringing");
}
if status & 0b11 == 0 {
println!("Buds stopped ringing, exiting...");
return Ok(());
}
},
Some(Err(e)) => {
Err(e)?;
},
None => {
let err = std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"connection closed"
);
Err(err)?;
}
}
},
_ = tokio::time::sleep_until(timeout) => {
println!("Sending command to stop ringing...");
// send message to stop ringing
let msg = Message {
group: EventGroup::DeviceAction.into(),
code: DeviceActionEventCode::Ring.into(),
data: smallvec![0x00],
};
stream.send(&msg).await?;
timeout = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
},
}
}
}
async fn connect_device_to_profile(profile: &mut ProfileHandle, dev: &Device)
-> bluer::Result<bluer::rfcomm::Stream>
{
loop {
tokio::select! {
res = async {
let _ = dev.connect().await;
dev.connect_profile(&gfps::msg::UUID).await
} => {
if let Err(err) = res {
println!("Connecting GFPS profile failed: {:?}", err);
}
tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
},
req = profile.next() => {
let req = req.expect("no connection request received");
if req.device() == dev.address() {
// accept our device
break req.accept();
} else {
// reject unknown devices
req.reject(ReqError::Rejected);
}
},
}
}
}

6
libgfps/src/lib.rs Normal file
View File

@@ -0,0 +1,6 @@
//! Library for the Google Fast Pair Service protocol (GFPS). Focussed on
//! communication via the dedicated GFPS RFCOMM channel.
//!
//! See <https://developers.google.com/nearby/fast-pair> for the specification.
pub mod msg;

184
libgfps/src/msg/codec.rs Normal file
View File

@@ -0,0 +1,184 @@
use super::Message;
use bytes::{Buf, BytesMut, BufMut};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_util::codec::{Decoder, Framed, Encoder};
const MAX_FRAME_SIZE: u16 = 4096;
pub struct Codec {}
impl Codec {
pub fn new() -> Self {
Self {}
}
pub fn wrap<T>(self, io: T) -> Framed<T, Codec>
where
T: AsyncRead + AsyncWrite,
{
Framed::with_capacity(io, self, MAX_FRAME_SIZE as _)
}
}
impl Default for Codec {
fn default() -> Self {
Self::new()
}
}
impl Decoder for Codec {
type Item = Message;
type Error = std::io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
if src.len() < 4 {
return Ok(None);
}
let group = src[0];
let code = src[1];
let mut length = [0; 2];
length.copy_from_slice(&src[2..4]);
let length = u16::from_be_bytes(length);
if length > MAX_FRAME_SIZE {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Frame of length {length} is too large (group: {group}, code: {code})."),
))?;
}
let size = 4 + length as usize;
if src.len() < size as _ {
src.reserve(size - src.len());
return Ok(None);
}
let data = src[4..size].into();
src.advance(size);
Ok(Some(Message {
group,
code,
data,
}))
}
}
impl Encoder<&Message> for Codec {
type Error = std::io::Error;
fn encode(&mut self, msg: &Message, buf: &mut BytesMut) -> Result<(), Self::Error> {
let size = msg.data.len() + 4;
if size > MAX_FRAME_SIZE as usize {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Frame of length {size} is too large."),
))?;
}
buf.reserve(size);
buf.put_u8(msg.group);
buf.put_u8(msg.code);
buf.put_slice(&(msg.data.len() as u16).to_be_bytes());
buf.put_slice(&msg.data);
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::msg::{EventGroup, DeviceEventCode, Message};
use bytes::BytesMut;
use smallvec::smallvec;
#[test]
fn test_encode() {
let mut buf = BytesMut::new();
let mut codec = Codec::new();
let msg = Message {
group: EventGroup::Device.into(),
code: DeviceEventCode::ModelId.into(),
data: smallvec![0x00, 0x01, 0x02, 0x04, 0x05],
};
// try to encode the message
codec.encode(&msg, &mut buf)
.expect("error encode message");
let raw = [0x03, 0x01, 0x00, 0x05, 0x00, 0x01, 0x02, 0x04, 0x05];
assert_eq!(&buf[..], &raw[..]);
}
#[test]
fn test_decode() {
let mut codec = Codec::new();
let raw = [0x03, 0x01, 0x00, 0x03, 0x00, 0x01, 0x02];
let mut buf = BytesMut::from(&raw[..]);
let msg = Message {
group: EventGroup::Device.into(),
code: DeviceEventCode::ModelId.into(),
data: smallvec![0x00, 0x01, 0x02],
};
// try to encode the message
let decoded = codec.decode(&mut buf)
.expect("error decoding message")
.expect("message incomplete");
assert_eq!(decoded, msg);
}
#[test]
fn test_decode_incomplete() {
let mut codec = Codec::new();
let raw = [0x03, 0x01, 0x00, 0x03, 0x00];
let mut buf = BytesMut::from(&raw[..]);
// try to encode the message
let decoded = codec.decode(&mut buf)
.expect("error decoding message");
assert_eq!(decoded, None);
}
#[test]
fn test_encode_decode() {
let mut buf = BytesMut::new();
let mut codec = Codec::new();
let msg = Message {
group: 0,
code: 0,
data: smallvec![0x00, 0x01, 0x02],
};
// try to encode the message
codec.encode(&msg, &mut buf)
.expect("error encode message");
// try to decode the message we just encoded
let decoded = codec.decode(&mut buf)
.expect("error decoding message")
.expect("message incomplete");
assert_eq!(decoded, msg);
}
}

14
libgfps/src/msg/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
//! Types for GFPS Message Stream via RFCOMM.
use uuid::{uuid, Uuid};
/// UUID under which the GFPS Message Stream is advertised.
///
/// Defined as `df21fe2c-2515-4fdb-8886-f12c4d67927c`.
pub const UUID: Uuid = uuid!("df21fe2c-2515-4fdb-8886-f12c4d67927c");
mod codec;
pub use codec::Codec;
mod types;
pub use types::*;

194
libgfps/src/msg/types.rs Normal file
View File

@@ -0,0 +1,194 @@
//! RFCOMM events and event-related enums.
use std::fmt::Display;
use num_enum::{IntoPrimitive, FromPrimitive};
use smallvec::SmallVec;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub group: u8,
pub code: u8,
pub data: SmallVec<[u8; 8]>,
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum EventGroup {
Bluetooth = 0x01,
Logging = 0x02,
Device = 0x03,
DeviceAction = 0x04,
DeviceConfiguration = 0x05,
DeviceCapabilitySync = 0x06,
SmartAudioSourceSwitching = 0x07,
Acknowledgement = 0xff,
#[num_enum(catch_all)]
Unknown(u8) = 0xfe,
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum BluetoothEventCode {
EnableSilenceMode = 0x01,
DisableSilenceMode = 0x02,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum LoggingEventCode {
LogFull = 0x01,
LogSaveToBuffer = 0x02,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum DeviceEventCode {
ModelId = 0x01,
BleAddress = 0x02,
BatteryInfo = 0x03,
BatteryTime = 0x04,
ActiveComponentsRequest = 0x05,
ActiveComponentsResponse = 0x06,
Capability = 0x07,
PlatformType = 0x08,
FirmwareVersion = 0x09,
SectionNonce = 0x0a,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum DeviceActionEventCode {
Ring = 0x01,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum DeviceConfigurationEventCode {
BufferSize = 0x01,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum DeviceCapabilitySyncEventCode {
CapabilityUpdate = 0x01,
ConfigurableBufferSizeRange = 0x02,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum SassEventCode {
GetCapabilityOfSass = 0x10,
NotifyCapabilityOfSass = 0x11,
SetMultiPointState = 0x12,
SwitchAudioSourceBetweenConnectedDevices = 0x30,
SwitchBack = 0x31,
NotifyMultiPointSwitchEvent = 0x32,
GetConnectionStatus = 0x33,
NotifyConnectionStatus = 0x34,
SassInitiatedConnection = 0x40,
IndicateInUseAccountKey = 0x41,
SetCustomData = 0x42,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum AcknowledgementEventCode {
Ack = 0x01,
Nak = 0x02,
#[num_enum(catch_all)]
Unknown(u8),
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum PlatformType {
Android = 0x01,
#[num_enum(catch_all)]
Unknown(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BatteryInfo {
#[default]
Unknown,
Known {
is_charging: bool,
percent: u8,
},
}
impl BatteryInfo {
pub fn from_byte(value: u8) -> Self {
if value & 0x7F == 0x7F {
BatteryInfo::Unknown
} else {
BatteryInfo::Known {
is_charging: (value & 0x80) != 0,
percent: value & 0x7F,
}
}
}
pub fn to_byte(&self) -> u8 {
match self {
BatteryInfo::Unknown => 0xFF,
BatteryInfo::Known { is_charging: true, percent } => 0x80 | (0x7F & percent),
BatteryInfo::Known { is_charging: false, percent } => 0x7F & percent,
}
}
}
impl Display for BatteryInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BatteryInfo::Unknown => {
write!(f, "unknown")
}
BatteryInfo::Known { is_charging: true, percent } => {
write!(f, "{percent}% (charging)")
}
BatteryInfo::Known { is_charging: false, percent } => {
write!(f, "{percent}% (not charging)")
}
}
}
}

30
libmaestro/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "maestro"
authors = ["Maximilian Luz <m@mxnluz.io>"]
version = "0.1.5"
edition = "2024"
license = "MIT/Apache-2.0"
description = "Maestro protocol client implementation for controlling Google Pixel Buds Pro"
repository = "https://github.com/qzed/pbpctrl"
[dependencies]
arrayvec = "0.7.6"
bytes = "1.10.1"
futures = "0.3.31"
num_enum = "0.7.3"
prost = "0.13.5"
tokio = { version = "1.44.2", features = ["macros"] }
tokio-util = { version = "0.7.14", features = ["codec"] }
tracing = "0.1.41"
uuid = "1.16.0"
[build-dependencies]
prost-build = "0.13.5"
[dev-dependencies]
anyhow = "1.0.98"
bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] }
futures = "0.3.31"
pretty-hex = "0.4.1"
tokio = { version = "1.44.2", features = ["rt", "macros", "signal"] }
tracing-subscriber = "0.3.19"

7
libmaestro/build.rs Normal file
View File

@@ -0,0 +1,7 @@
use std::io::Result;
fn main() -> Result<()> {
prost_build::compile_protos(&["proto/pw.rpc.packet.proto"], &["proto/"])?;
prost_build::compile_protos(&["proto/maestro_pw.proto"], &["proto/"])?;
Ok(())
}

View File

@@ -0,0 +1,89 @@
use std::time::Duration;
use anyhow::Result;
use bluer::{Address, Device, Session};
use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile};
use futures::StreamExt;
use maestro::pwrpc::Error;
use maestro::pwrpc::client::Client;
use maestro::pwrpc::types::RpcPacket;
pub async fn run_client<S, E>(mut client: Client<S>) -> Result<()>
where
S: futures::Sink<RpcPacket>,
S: futures::Stream<Item = Result<RpcPacket, E>> + Unpin,
Error: From<E>,
Error: From<S::Error>,
{
tokio::select! {
res = client.run() => {
res?;
},
sig = tokio::signal::ctrl_c() => {
sig?;
tracing::trace!("client termination requested");
},
}
client.terminate().await?;
Ok(())
}
pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result<Stream> {
let maestro_profile = Profile {
uuid: maestro::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
tracing::debug!("registering maestro profile");
let mut handle = session.register_profile(maestro_profile).await?;
tracing::debug!("connecting to maestro profile");
let stream = tokio::try_join!(
try_connect_profile(dev),
handle_requests_for_profile(&mut handle, dev.address()),
)?.1;
Ok(stream)
}
async fn try_connect_profile(dev: &Device) -> Result<()> {
const RETRY_TIMEOUT: Duration = Duration::from_secs(1);
const MAX_TRIES: u32 = 3;
let mut i = 0;
while let Err(err) = dev.connect_profile(&maestro::UUID).await {
if i >= MAX_TRIES { return Err(err.into()) }
i += 1;
tracing::warn!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES);
tokio::time::sleep(RETRY_TIMEOUT).await;
}
tracing::debug!(address=%dev.address(), "maestro profile connected");
Ok(())
}
async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result<Stream> {
while let Some(req) = handle.next().await {
tracing::debug!(address=%req.device(), "received new profile connection request");
if req.device() == address {
tracing::debug!(address=%req.device(), "accepting profile connection request");
return Ok(req.accept()?);
} else {
req.reject(ReqError::Rejected);
}
}
anyhow::bail!("profile terminated without requests")
}

View File

@@ -0,0 +1,139 @@
//! Simple example for reading battery info via the Maestro service.
//!
//! Usage:
//! cargo run --example maestro_get_battery -- <bluetooth-device-address>
mod common;
use std::str::FromStr;
use anyhow::bail;
use bluer::{Address, Session};
use futures::StreamExt;
use maestro::protocol::codec::Codec;
use maestro::protocol::types::RuntimeInfo;
use maestro::protocol::utils;
use maestro::pwrpc::client::{Client, ClientHandle};
use maestro::service::MaestroService;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), anyhow::Error> {
tracing_subscriber::fmt::init();
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
println!("Using adapter '{}'", adapter.name());
// get device
let dev = adapter.device(addr)?;
let uuids = {
let mut uuids = Vec::from_iter(dev.uuids().await?
.unwrap_or_default()
.into_iter());
uuids.sort_unstable();
uuids
};
println!("Found device:");
println!(" alias: {}", dev.alias().await?);
println!(" address: {}", dev.address());
println!(" paired: {}", dev.is_paired().await?);
println!(" connected: {}", dev.is_connected().await?);
println!(" UUIDs:");
for uuid in uuids {
println!(" {}", uuid);
}
println!();
println!("Connecting to Maestro profile");
let stream = common::connect_maestro_rfcomm(&session, &dev).await?;
println!("Profile connected");
// set up stream for RPC communication
let codec = Codec::new();
let stream = codec.wrap(stream);
// set up RPC client
let mut client = Client::new(stream);
let handle = client.handle();
// retreive the channel numer
let channel = utils::resolve_channel(&mut client).await?;
let exec_task = common::run_client(client);
let battery_task = get_battery(handle, channel);
let info = tokio::select! {
res = exec_task => {
match res {
Ok(_) => bail!("client terminated unexpectedly without error"),
Err(e) => Err(e),
}
},
res = battery_task => res,
}?;
let info = info.battery_info
.expect("did not receive battery status in runtime-info-changed event");
println!("Battery status:");
if let Some(info) = info.case {
match info.state {
1 => println!(" case: {}% (not charging)", info.level),
2 => println!(" case: {}% (charging)", info.level),
x => println!(" case: {}% (unknown state: {})", info.level, x),
}
} else {
println!(" case: unknown");
}
if let Some(info) = info.left {
match info.state {
1 => println!(" left: {}% (not charging)", info.level),
2 => println!(" left: {}% (charging)", info.level),
x => println!(" left: {}% (unknown state: {})", info.level, x),
}
} else {
println!(" left: unknown");
}
if let Some(info) = info.right {
match info.state {
1 => println!(" right: {}% (not charging)", info.level),
2 => println!(" right: {}% (charging)", info.level),
x => println!(" right: {}% (unknown state: {})", info.level, x),
}
} else {
println!(" right: unknown");
}
Ok(())
}
async fn get_battery(handle: ClientHandle, channel: u32) -> anyhow::Result<RuntimeInfo> {
println!("Reading battery info...");
println!();
let mut service = MaestroService::new(handle, channel);
let mut call = service.subscribe_to_runtime_info()?;
let rt_info = if let Some(msg) = call.stream().next().await {
msg?
} else {
bail!("did not receive any runtime-info event");
};
call.cancel_and_wait().await?;
Ok(rt_info)
}

View File

@@ -0,0 +1,172 @@
//! Simple example for listening to Maestro messages sent via the RFCOMM channel.
//!
//! Usage:
//! cargo run --example maestro_listen -- <bluetooth-device-address>
mod common;
use std::str::FromStr;
use bluer::{Address, Session};
use futures::StreamExt;
use maestro::protocol::codec::Codec;
use maestro::protocol::utils;
use maestro::pwrpc::client::{Client, ClientHandle};
use maestro::service::{MaestroService, DosimeterService};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), anyhow::Error> {
tracing_subscriber::fmt::init();
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
println!("Using adapter '{}'", adapter.name());
// get device
let dev = adapter.device(addr)?;
let uuids = {
let mut uuids = Vec::from_iter(dev.uuids().await?
.unwrap_or_default()
.into_iter());
uuids.sort_unstable();
uuids
};
println!("Found device:");
println!(" alias: {}", dev.alias().await?);
println!(" address: {}", dev.address());
println!(" paired: {}", dev.is_paired().await?);
println!(" connected: {}", dev.is_connected().await?);
println!(" UUIDs:");
for uuid in uuids {
println!(" {}", uuid);
}
println!();
// try to reconnect if connection is reset
loop {
println!("Connecting to Maestro profile");
let stream = common::connect_maestro_rfcomm(&session, &dev).await?;
println!("Profile connected");
// set up stream for RPC communication
let codec = Codec::new();
let stream = codec.wrap(stream);
// set up RPC client
let mut client = Client::new(stream);
let handle = client.handle();
// retreive the channel numer
let channel = utils::resolve_channel(&mut client).await?;
let exec_task = common::run_client(client);
let listen_task = run_listener(handle, channel);
tokio::select! {
res = exec_task => {
match res {
Ok(_) => {
tracing::trace!("client terminated successfully");
return Ok(());
},
Err(e) => {
tracing::error!("client task terminated with error");
let cause = e.root_cause();
if let Some(cause) = cause.downcast_ref::<std::io::Error>() {
if cause.raw_os_error() == Some(104) {
// The Pixel Buds Pro can hand off processing between each
// other. On a switch, the connection is reset. Wait a bit
// and then try to reconnect.
println!();
println!("Connection reset. Attempting to reconnect...");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
continue;
}
}
return Err(e);
},
}
},
res = listen_task => {
match res {
Ok(_) => {
tracing::error!("server terminated stream");
return Ok(());
}
Err(e) => {
tracing::error!("main task terminated with error");
return Err(e);
}
}
},
}
}
}
async fn run_listener(handle: ClientHandle, channel: u32) -> anyhow::Result<()> {
println!("Sending GetSoftwareInfo request");
println!();
let mut service = MaestroService::new(handle.clone(), channel);
let mut dosimeter = DosimeterService::new(handle, channel);
let info = service.get_software_info().await?;
println!("{:#?}", info);
let info = dosimeter.fetch_daily_summaries().await?;
println!("{:#?}", info);
println!();
println!("Listening to settings changes...");
println!();
let task_rtinfo = run_listener_rtinfo(service.clone());
let task_settings = run_listener_settings(service.clone());
let task_dosimeter = run_listener_dosimeter(dosimeter.clone());
tokio::select! {
res = task_rtinfo => res,
res = task_settings => res,
res = task_dosimeter => res,
}
}
async fn run_listener_rtinfo(mut service: MaestroService) -> anyhow::Result<()> {
let mut call = service.subscribe_to_runtime_info()?;
while let Some(msg) = call.stream().next().await {
println!("{:#?}", msg?);
}
Ok(())
}
async fn run_listener_settings(mut service: MaestroService) -> anyhow::Result<()> {
let mut call = service.subscribe_to_settings_changes()?;
while let Some(msg) = call.stream().next().await {
println!("{:#?}", msg?);
}
Ok(())
}
async fn run_listener_dosimeter(mut service: DosimeterService) -> anyhow::Result<()> {
let mut call = service.subscribe_to_live_db()?;
while let Some(msg) = call.stream().next().await {
println!("volume: {:#?} dB", (msg.unwrap().intensity.log10() * 10.0).round());
}
Ok(())
}

View File

@@ -0,0 +1,152 @@
//! Simple example for reading settings on the Pixel Buds Pro via the Maestro service.
//!
//! Usage:
//! cargo run --example maestro_read_settings -- <bluetooth-device-address>
mod common;
use std::str::FromStr;
use anyhow::bail;
use bluer::{Address, Session};
use maestro::protocol::codec::Codec;
use maestro::protocol::utils;
use maestro::pwrpc::client::{Client, ClientHandle};
use maestro::service::MaestroService;
use maestro::service::settings::{self, SettingId};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), anyhow::Error> {
tracing_subscriber::fmt::init();
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
println!("Using adapter '{}'", adapter.name());
// get device
let dev = adapter.device(addr)?;
let uuids = {
let mut uuids = Vec::from_iter(dev.uuids().await?
.unwrap_or_default()
.into_iter());
uuids.sort_unstable();
uuids
};
println!("Found device:");
println!(" alias: {}", dev.alias().await?);
println!(" address: {}", dev.address());
println!(" paired: {}", dev.is_paired().await?);
println!(" connected: {}", dev.is_connected().await?);
println!(" UUIDs:");
for uuid in uuids {
println!(" {}", uuid);
}
println!();
println!("Connecting to Maestro profile");
let stream = common::connect_maestro_rfcomm(&session, &dev).await?;
println!("Profile connected");
// set up stream for RPC communication
let codec = Codec::new();
let stream = codec.wrap(stream);
// set up RPC client
let mut client = Client::new(stream);
let handle = client.handle();
// retreive the channel numer
let channel = utils::resolve_channel(&mut client).await?;
let exec_task = common::run_client(client);
let settings_task = read_settings(handle, channel);
tokio::select! {
res = exec_task => {
match res {
Ok(_) => bail!("client terminated unexpectedly without error"),
Err(e) => Err(e),
}
},
res = settings_task => res,
}
}
async fn read_settings(handle: ClientHandle, channel: u32) -> anyhow::Result<()> {
let mut service = MaestroService::new(handle.clone(), channel);
println!();
println!("Read via types:");
// read some typed settings via proxy structs
let value = service.read_setting(settings::id::AutoOtaEnable).await?;
println!(" Auto-OTA enabled: {}", value);
let value = service.read_setting(settings::id::OhdEnable).await?;
println!(" OHD enabled: {}", value);
let value = service.read_setting(settings::id::OobeIsFinished).await?;
println!(" OOBE finished: {}", value);
let value = service.read_setting(settings::id::GestureEnable).await?;
println!(" Gestures enabled: {}", value);
let value = service.read_setting(settings::id::DiagnosticsEnable).await?;
println!(" Diagnostics enabled: {}", value);
let value = service.read_setting(settings::id::OobeMode).await?;
println!(" OOBE mode: {}", value);
let value = service.read_setting(settings::id::GestureControl).await?;
println!(" Gesture control: {}", value);
let value = service.read_setting(settings::id::MultipointEnable).await?;
println!(" Multi-point enabled: {}", value);
let value = service.read_setting(settings::id::AncrGestureLoop).await?;
println!(" ANCR gesture loop: {}", value);
let value = service.read_setting(settings::id::CurrentAncrState).await?;
println!(" ANC status: {}", value);
let value = service.read_setting(settings::id::OttsMode).await?;
println!(" OTTS mode: {}", value);
let value = service.read_setting(settings::id::VolumeEqEnable).await?;
println!(" Volume-EQ enabled: {}", value);
let value = service.read_setting(settings::id::CurrentUserEq).await?;
println!(" Current user EQ: {}", value);
let value = service.read_setting(settings::id::VolumeAsymmetry).await?;
println!(" Volume balance/asymmetry: {}", value);
let value = service.read_setting(settings::id::SumToMono).await?;
println!(" Mono output: {}", value);
let value = service.read_setting(settings::id::VolumeExposureNotifications).await?;
println!(" Volume level exposure notifications: {}", value);
let value = service.read_setting(settings::id::SpeechDetection).await?;
println!(" Speech detection: {}", value);
// read settings via variant
println!();
println!("Read via variants:");
let value = service.read_setting(SettingId::GestureEnable).await?;
println!(" Gesture enable: {:?}", value);
Ok(())
}

View File

@@ -0,0 +1,106 @@
//! Simple example for changing settings on the Pixel Buds Pro via the Maestro service.
//!
//! Sets active nois ecancelling (ANC) state. 1: off, 2: active, 3: aware, 4.adaptive
//!
//! Usage:
//! cargo run --example maestro_write_settings -- <bluetooth-device-address> <anc-state>
mod common;
use std::str::FromStr;
use anyhow::bail;
use bluer::{Address, Session};
use maestro::protocol::utils;
use num_enum::FromPrimitive;
use maestro::protocol::codec::Codec;
use maestro::pwrpc::client::{Client, ClientHandle};
use maestro::service::MaestroService;
use maestro::service::settings::{AncState, SettingValue};
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), anyhow::Error> {
tracing_subscriber::fmt::init();
// handle command line arguments
let addr = std::env::args().nth(1).expect("need device address as argument");
let addr = Address::from_str(&addr)?;
let anc_state = std::env::args().nth(2).expect("need ANC state as argument");
let anc_state = i32::from_str(&anc_state)?;
let anc_state = AncState::from_primitive(anc_state);
if let AncState::Unknown(x) = anc_state {
bail!("invalid ANC state {x}");
}
// set up session
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
println!("Using adapter '{}'", adapter.name());
// get device
let dev = adapter.device(addr)?;
let uuids = {
let mut uuids = Vec::from_iter(dev.uuids().await?
.unwrap_or_default()
.into_iter());
uuids.sort_unstable();
uuids
};
println!("Found device:");
println!(" alias: {}", dev.alias().await?);
println!(" address: {}", dev.address());
println!(" paired: {}", dev.is_paired().await?);
println!(" connected: {}", dev.is_connected().await?);
println!(" UUIDs:");
for uuid in uuids {
println!(" {}", uuid);
}
println!();
println!("Connecting to Maestro profile");
let stream = common::connect_maestro_rfcomm(&session, &dev).await?;
println!("Profile connected");
// set up stream for RPC communication
let codec = Codec::new();
let stream = codec.wrap(stream);
// set up RPC client
let mut client = Client::new(stream);
let handle = client.handle();
// retreive the channel numer
let channel = utils::resolve_channel(&mut client).await?;
let exec_task = common::run_client(client);
let settings_task = read_settings(handle, channel, anc_state);
tokio::select! {
res = exec_task => {
match res {
Ok(_) => bail!("client terminated unexpectedly without error"),
Err(e) => Err(e),
}
},
res = settings_task => res,
}
}
async fn read_settings(handle: ClientHandle, channel: u32, anc_state: AncState) -> anyhow::Result<()> {
let mut service = MaestroService::new(handle.clone(), channel);
println!();
println!("Setting ANC status to '{}'", anc_state);
service.write_setting(SettingValue::CurrentAncrState(anc_state)).await?;
Ok(())
}

View File

@@ -0,0 +1,301 @@
syntax = "proto3";
package maestro_pw;
import "google/protobuf/empty.proto";
/* -- Maestro Service --------------------------------------------------------------------------- */
message SoftwareInfo {
int32 unknown2 = 2;
FirmwareInfo firmware = 4;
fixed64 unknown5 = 5;
bool unknown6 = 6;
}
message FirmwareInfo {
// Note: order might not be correct
FirmwareVersion case = 1;
FirmwareVersion right = 2;
FirmwareVersion left = 3;
}
message FirmwareVersion {
string unknown = 1;
string version_string = 2;
}
message HardwareInfo {
int32 unknown1 = 1;
int32 unknown2 = 2;
int32 unknown5 = 5;
int32 unknown6 = 6;
SerialNumbers serial_number = 7;
}
message SerialNumbers {
string case = 1;
string right = 2;
string left = 3;
}
message RuntimeInfo {
int64 timestamp_ms = 2; // maybe unix time in ms (consistent ~60s difference to actual time)
int32 unknown3 = 3;
BatteryInfo battery_info = 6;
PlacementInfo placement = 7;
}
message BatteryInfo {
DeviceBatteryInfo case = 1;
DeviceBatteryInfo left = 2;
DeviceBatteryInfo right = 3;
}
message DeviceBatteryInfo {
int32 level = 1; // battery level in percent
BatteryState state = 2;
}
enum BatteryState {
BATTERY_STATE_UNKNOWN = 0;
BATTERY_NOT_CHARGING = 1;
BATTERY_CHARGING = 2;
}
message PlacementInfo {
bool right_bud_in_case = 1;
bool left_bud_in_case = 2;
}
message WallClockMsg {
// TODO
}
message ReadSettingMsg {
oneof value_oneof {
AllegroSettingType settings_id = 4;
}
}
enum AllegroSettingType {
ALLEGRO_SETTING_TYPE_UNKNOWN = 0;
ALLEGRO_AUTO_OTA_ENABLE = 1;
ALLEGRO_OHD_ENABLE = 2;
ALLEGRO_OOBE_IS_FINISHED = 3;
ALLEGRO_GESTURE_ENABLE = 4;
ALLEGRO_DIAGNOSTICS_ENABLE = 5;
ALLEGRO_OOBE_MODE = 6;
ALLEGRO_GESTURE_CONTROL = 7;
ALLEGRO_ANC_ACCESSIBILITY_MODE = 8;
ALLEGRO_ANCR_STATE_ONE_BUD = 9;
ALLEGRO_ANCR_STATE_TWO_BUDS = 10;
ALLEGRO_MULTIPOINT_ENABLE = 11;
ALLEGRO_ANCR_GESTURE_LOOP = 12;
ALLEGRO_CURRENT_ANCR_STATE = 13;
ALLEGRO_OTTS_MODE = 14;
ALLEGRO_VOLUME_EQ_ENABLE = 15;
ALLEGRO_CURRENT_USER_EQ = 16;
ALLEGRO_VOLUME_ASYMMETRY = 17;
ALLEGRO_LAST_SAVED_USER_EQ = 18;
}
message WriteSettingMsg {
oneof value_oneof {
SettingValue setting = 4;
}
}
message SettingsRsp {
oneof value_oneof {
SettingValue value = 4;
}
}
message SettingValue {
oneof value_oneof {
bool auto_ota_enable = 1;
bool ohd_enable = 2; // on-head detection
bool oobe_is_finished = 3; // out-of-box experience?
bool gesture_enable = 4;
bool diagnostics_enable = 5;
bool oobe_mode = 6;
GestureControl gesture_control = 7;
// reading anc_accessibility_mode returns non-zero status (code: 2)
// reading ancr_state_one_bud returns non-zero status (code: 2)
// reading ancr_state_two_buds returns non-zero status (code: 2)
bool multipoint_enable = 11;
AncrGestureLoop ancr_gesture_loop = 12;
AncState current_ancr_state = 13;
int32 otts_mode = 14; // might be bool
bool volume_eq_enable = 15;
EqBands current_user_eq = 16;
int32 volume_asymmetry = 17; // value goes from 0 t0 200 (incl.), even/odd indicates left/right
// reading last_saved_user_eq returns non-zero status (code: 2)
bool sum_to_mono = 19;
// id 20 does not seem to exist (yet?)
bool volume_exposure_notifications = 21;
bool speech_detection = 22;
}
}
message GestureControl {
DeviceGestureControl left = 1;
DeviceGestureControl right = 2;
}
message DeviceGestureControl {
oneof value_oneof {
GestureControlType type = 4;
}
}
message GestureControlType {
RegularActionTarget value = 1;
}
enum RegularActionTarget {
ACTION_TARGET_UNKNOWN = 0;
ACTION_TARGET_CHECK_NOTIFICATIONS = 1;
ACTION_TARGET_PREVIOUS_TRACK_REPEAT = 2;
ACTION_TARGET_NEXT_TRACK = 3;
ACTION_TARGET_PLAY_PAUSE_TRACK = 4;
ACTION_TARGET_ANC_CONTROL = 5;
ACTION_TARGET_ASSISTANT_QUERY = 6;
}
message AncrGestureLoop {
bool active = 1;
bool off = 2;
bool aware = 3;
bool adaptive = 4;
}
enum AncState {
ANC_STATE_UNKNOWN = 0;
ANC_STATE_OFF = 1;
ANC_STATE_ACTIVE = 2;
ANC_STATE_AWARE = 3;
ANC_STATE_ADAPTIVE = 4;
}
message EqBands {
// bands go from -6.0 to 6.0
float low_bass = 1;
float bass = 2;
float mid = 3;
float treble = 4;
float upper_treble = 5;
}
message OobeActionRsp {
OobeAction action = 1;
}
enum OobeAction {
OOBE_ACTION_UNKNOWN = 0;
OOBE_ACTION_SINGLE_TAP = 1;
OOBE_ACTION_DOUBLE_TAP = 2;
OOBE_ACTION_TRIPLE_TAP = 3;
OOBE_ACTION_HOLD = 4;
OOBE_ACTION_SWIPE_FORWARD = 5;
OOBE_ACTION_SWIPE_BACKWARD = 6;
OOBE_ACTION_SWIPE_UP = 7;
OOBE_ACTION_SWIPE_DOWN = 8;
OOBE_ACTION_HOTWORD = 9;
OOBE_ACTION_LEFT_ON_HEAD = 10;
OOBE_ACTION_LEFT_OFF_HEAD = 11;
OOBE_ACTION_RIGHT_ON_HEAD = 12;
OOBE_ACTION_RIGHT_OFF_HEAD = 13;
OOBE_ACTION_SPECULATIVE_TAP = 14;
OOBE_ACTION_HOLD_END = 15;
OOBE_ACTION_HOLD_CANCEL = 16;
}
service Maestro {
rpc GetSoftwareInfo(google.protobuf.Empty) returns (SoftwareInfo) {}
rpc GetHardwareInfo(google.protobuf.Empty) returns (HardwareInfo) {}
rpc SubscribeRuntimeInfo(google.protobuf.Empty) returns (stream RuntimeInfo) {}
rpc SetWallClock(WallClockMsg) returns (google.protobuf.Empty) {}
rpc WriteSetting(WriteSettingMsg) returns (google.protobuf.Empty) {}
rpc ReadSetting(ReadSettingMsg) returns (SettingsRsp) {}
rpc SubscribeToSettingsChanges(google.protobuf.Empty) returns (stream SettingsRsp) {}
rpc SubscribeToOobeActions(google.protobuf.Empty) returns (stream OobeActionRsp) {}
}
/* -- Multipoint Service ------------------------------------------------------------------------ */
message QuietModeStatusEvent {
int32 source = 1;
}
message ForceMultipointSwitchMsg {
// TODO
}
service Multipoint {
rpc SubscribeToQuietModeStatus(google.protobuf.Empty) returns (stream QuietModeStatusEvent) {}
rpc ForceMultipointSwitch(ForceMultipointSwitchMsg) returns (google.protobuf.Empty) {}
}
/* -- EartipFitTest Service --------------------------------------------------------------------- */
message StartEartipFitTestMsg {
// TODO
}
message EndEartipFitTestMsg {
// TODO
}
message SubscribeToEartipFitTestResultsMsg {
// TODO
}
message EartipFitTestResult {
// TODO
}
service EartipFitTest {
rpc StartTest(StartEartipFitTestMsg) returns (google.protobuf.Empty) {}
rpc EndTest(StartEartipFitTestMsg) returns (google.protobuf.Empty) {}
rpc SubscribeToResults(SubscribeToEartipFitTestResultsMsg) returns (stream EartipFitTestResult) {}
}
/* -- JitterBuffer Service ---------------------------------------------------------------------- */
message SetJitterBufferSizePreferenceMsg {
// TODO
}
service JitterBuffer {
rpc SetJitterBufferSizePreference(SetJitterBufferSizePreferenceMsg) returns (google.protobuf.Empty) {}
}
/* -- Dosimeter Service ------------------------------------------------------------------------- */
message DosimeterSummaryEntry {
int32 unknown1 = 1;
float unknown6 = 6;
}
message DosimeterSummary {
int32 unknown1 = 1;
repeated DosimeterSummaryEntry unknown2 = 2;
int32 unknown4 = 4;
float unknown5 = 5;
}
message DosimeterLiveDbMsg {
float intensity = 2; // convert to dB via log10(x) * 10
}
service Dosimeter {
rpc FetchDailySummaries(google.protobuf.Empty) returns (DosimeterSummary) {}
rpc SubscribeToLiveDb(google.protobuf.Empty) returns (DosimeterLiveDbMsg) {}
}

View File

@@ -0,0 +1,87 @@
// Copied from pigweed RPC library with modifications.
// - Changed package specification.
// - Removed java-package option.
//
// Original copyright:
// Copyright 2020 The Pigweed Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain a
// copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
syntax = "proto3";
package pw.rpc.packet;
enum PacketType {
// To simplify identifying the origin of a packet, client-to-server packets
// use even numbers and server-to-client packets use odd numbers.
// Client-to-server packets
// The client invokes an RPC. Always the first packet.
REQUEST = 0;
// A message in a client stream. Always sent after a REQUEST and before a
// CLIENT_STREAM_END.
CLIENT_STREAM = 2;
// The client received a packet for an RPC it did not request.
CLIENT_ERROR = 4;
// Deprecated, do not use. Send a CLIENT_ERROR with status CANCELLED instead.
// TODO(b/234879973): Remove this packet type.
DEPRECATED_CANCEL = 6;
// A client stream has completed.
CLIENT_STREAM_END = 8;
// Server-to-client packets
// The RPC has finished.
RESPONSE = 1;
// Deprecated, do not use. Formerly was used as the last packet in a server
// stream.
// TODO(b/234879973): Remove this packet type.
DEPRECATED_SERVER_STREAM_END = 3;
// The server was unable to process a request.
SERVER_ERROR = 5;
// A message in a server stream.
SERVER_STREAM = 7;
}
message RpcPacket {
// The type of packet. Determines which other fields are used.
PacketType type = 1;
// Channel through which the packet is sent.
uint32 channel_id = 2;
// Hash of the fully-qualified name of the service with which this packet is
// associated. For RPC packets, this is the service that processes the packet.
fixed32 service_id = 3;
// Hash of the name of the method which should process this packet.
fixed32 method_id = 4;
// The packet's payload, which is an encoded protobuf.
bytes payload = 5;
// Status code for the RPC response or error.
uint32 status = 6;
// Unique identifier for the call that initiated this RPC. Optionally set by
// the client in the initial request and sent in all subsequent client
// packets; echoed by the server.
uint32 call_id = 7;
}

View File

@@ -0,0 +1,72 @@
use super::{decoder, encoder, Frame};
use bytes::BytesMut;
use tokio::io::{AsyncWrite, AsyncRead};
use tokio_util::codec::Framed;
#[derive(Debug)]
pub enum DecoderError {
Io(std::io::Error),
Decoder(decoder::Error),
}
impl From<std::io::Error> for DecoderError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<decoder::Error> for DecoderError {
fn from(value: decoder::Error) -> Self {
Self::Decoder(value)
}
}
#[derive(Debug, Default)]
pub struct Codec {
dec: decoder::Decoder,
}
impl Codec {
pub fn new() -> Self {
Self { dec: decoder::Decoder::new() }
}
pub fn with_capacity(cap: usize) -> Self {
Self { dec: decoder::Decoder::with_capacity(cap) }
}
pub fn wrap<T>(self, io: T) -> Framed<T, Codec>
where
T: AsyncRead + AsyncWrite,
{
Framed::with_capacity(io, self, 4096 as _)
}
}
impl tokio_util::codec::Encoder<&Frame> for Codec {
type Error = std::io::Error;
fn encode(&mut self, frame: &Frame, dst: &mut BytesMut) -> Result<(), Self::Error> {
encoder::encode(dst, frame);
Ok(())
}
}
impl tokio_util::codec::Decoder for Codec {
type Item = Frame;
type Error = std::io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
match self.dec.process(src) {
Ok(x) => Ok(x),
Err(e) => {
tracing::warn!("error decoding data: {e:?}");
Ok(None)
},
}
}
}

View File

@@ -0,0 +1,10 @@
//! Flag bytes and bit masks used in the HDLC encoding.
pub mod flags {
pub const FRAME: u8 = 0x7E;
pub const ESCAPE: u8 = 0x7D;
}
pub mod escape {
pub const MASK: u8 = 0x20;
}

109
libmaestro/src/hdlc/crc.rs Normal file
View File

@@ -0,0 +1,109 @@
//! 32-bit CRC implementation.
#[derive(Debug)]
pub struct Crc32 {
state: u32,
}
impl Crc32 {
pub fn new() -> Self {
Self::with_state(0xFFFFFFFF)
}
pub fn with_state(state: u32) -> Self {
Self { state }
}
pub fn reset(&mut self) {
self.state = 0xFFFFFFFF;
}
pub fn value(&self) -> u32 {
!self.state
}
pub fn put_u8(&mut self, byte: u8) -> &mut Self {
self.state = tables::CRC32[((self.state as u8) ^ byte) as usize] ^ (self.state >> 8);
self
}
pub fn put_bytes<'a, B: IntoIterator<Item=&'a u8>>(&mut self, bytes: B) -> &mut Self {
for b in bytes.into_iter().copied() {
self.put_u8(b);
}
self
}
}
impl Default for Crc32 {
fn default() -> Self {
Self::new()
}
}
pub fn crc32<'a, B: IntoIterator<Item=&'a u8>>(bytes: B) -> u32 {
Crc32::new().put_bytes(bytes).value()
}
mod tables {
pub const CRC32: [u32; 256] = [
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f,
0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, 0x1db71064, 0x6ab020f2,
0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9,
0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, 0x35b5a8fa, 0x42b2986c,
0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423,
0xcfba9599, 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, 0x01db7106,
0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d,
0x91646c97, 0xe6635c01, 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950,
0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7,
0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa,
0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81,
0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, 0xe3630b12, 0x94643b84,
0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb,
0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, 0xd6d6a3e8, 0xa1d1937e,
0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55,
0x316e8eef, 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, 0xb2bd0b28,
0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f,
0x72076785, 0x05005713, 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242,
0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69,
0x616bffd3, 0x166ccf45, 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc,
0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693,
0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
];
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_crc32() {
assert_eq!(crc32(b"test test test"), 0x235b6a02);
assert_eq!(crc32(b"1234321"), 0xd981751c);
}
}

View File

@@ -0,0 +1,328 @@
use bytes::{Buf, BytesMut};
use super::consts;
use super::crc;
use super::varint;
use super::Frame;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
UnexpectedData,
UnexpectedEndOfFrame,
InvalidChecksum,
InvalidEncoding,
InvalidFrame,
InvalidAddress,
BufferOverflow,
}
impl From<varint::DecodeError> for Error {
fn from(value: varint::DecodeError) -> Self {
match value {
varint::DecodeError::Incomplete => Self::InvalidFrame,
varint::DecodeError::Overflow => Self::InvalidAddress,
}
}
}
#[derive(Debug)]
pub struct Decoder {
buf: Vec<u8>,
state: (State, EscState),
current_frame_size: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Discard,
Frame,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EscState {
Normal,
Escape,
}
impl Decoder {
pub fn new() -> Self {
Self::with_capacity(4096)
}
pub fn with_capacity(cap: usize) -> Self {
Self {
buf: Vec::with_capacity(cap),
state: (State::Discard, EscState::Normal),
current_frame_size: 0,
}
}
pub fn process(&mut self, buf: &mut BytesMut) -> Result<Option<Frame>, Error> {
if buf.is_empty() {
return Ok(None);
}
loop {
match self.state.0 {
State::Discard => {
// try to find the start of this frame
match find_frame_start(buf) {
// expected: immediate start of frame
Some(0) => {
self.state.0 = State::Frame;
buf.advance(1);
},
// unexpected: n bytes before start of frame
Some(n) => {
self.state.0 = State::Frame;
buf.advance(n + 1);
return Err(Error::UnexpectedData);
},
// unexpected: unknown amount of bytes before start of frame
None => {
// check whether the last byte might indicate a start
let n = if buf.last() == Some(&consts::flags::FRAME) {
buf.len() - 1
} else {
buf.len()
};
buf.advance(n);
return Err(Error::UnexpectedData);
},
}
},
State::Frame => {
// copy and decode to internal buffer
for (i, b) in buf.iter().copied().enumerate() {
match (b, self.state.1) {
(consts::flags::ESCAPE, EscState::Normal) => {
self.state.1 = EscState::Escape;
},
(consts::flags::ESCAPE, EscState::Escape) => {
buf.advance(i + 1);
self.reset();
return Err(Error::InvalidEncoding);
},
(consts::flags::FRAME, EscState::Normal) => {
buf.advance(i + 1);
return self.decode_buffered();
},
(consts::flags::FRAME, EscState::Escape) => {
buf.advance(i);
self.reset();
return Err(Error::UnexpectedEndOfFrame);
},
(b, EscState::Normal) => {
self.push_byte(b);
},
(b, EscState::Escape) => {
self.push_byte(b ^ consts::escape::MASK);
self.state.1 = EscState::Normal;
},
}
}
buf.advance(buf.remaining());
return Ok(None);
},
}
}
}
fn decode_buffered(&mut self) -> Result<Option<Frame>, Error> {
// validate minimum frame size
if self.buf.len() < 6 {
self.reset();
self.state.0 = State::Frame; // the next frame may already start
return Err(Error::InvalidFrame);
}
// validate checksum
let crc_actual = crc::crc32(&self.buf[..self.buf.len()-4]);
let crc_expect = self.buf[self.buf.len()-4..].try_into().unwrap();
let crc_expect = u32::from_le_bytes(crc_expect);
if crc_expect != crc_actual {
self.reset();
self.state.0 = State::Frame; // the next frame may already start
return Err(Error::InvalidChecksum);
}
// check for overflow
if self.current_frame_size > self.buf.len() {
self.reset();
return Err(Error::BufferOverflow);
}
// decode address
let (address, n) = varint::decode(&self.buf)?;
// validate minimum remaining frame size
if self.buf.len() < n + 5 {
self.reset();
return Err(Error::InvalidFrame);
}
// get control byte and data
let control = self.buf[n];
let data = self.buf[n+1..self.buf.len()-4].into();
let frame = Frame {
address,
control,
data,
};
self.reset();
Ok(Some(frame))
}
fn push_byte(&mut self, byte: u8) {
self.current_frame_size += 1;
if self.buf.len() < self.buf.capacity() {
self.buf.push(byte);
}
}
fn reset(&mut self) {
self.buf.clear();
self.state = (State::Discard, EscState::Normal);
self.current_frame_size = 0;
}
}
impl Default for Decoder {
fn default() -> Self {
Self::new()
}
}
fn find_frame_start(buf: &[u8]) -> Option<usize> {
buf.windows(2)
.enumerate()
.find(|(_, b)| b[0] == consts::flags::FRAME && b[1] != consts::flags::FRAME)
.map(|(i, _)| i)
}
#[cfg(test)]
mod test {
use bytes::BufMut;
use super::*;
#[test]
fn test_find_frame_start() {
let buf = [0x7E, 0x01, 0x02, 0x03];
assert_eq!(find_frame_start(&buf), Some(0));
let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x00, 0x01, 0x02, 0x03];
assert_eq!(find_frame_start(&buf), Some(4));
let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x7E, 0x00, 0x01, 0x02, 0x03];
assert_eq!(find_frame_start(&buf), Some(5));
let buf = [0x03, 0x02, 0x01, 0x00, 0x7E];
assert_eq!(find_frame_start(&buf), None);
let buf = [0x03, 0x02, 0x01, 0x00, 0x7E, 0x00];
assert_eq!(find_frame_start(&buf), Some(4));
let buf = [0x7E];
assert_eq!(find_frame_start(&buf), None);
let buf = [];
assert_eq!(find_frame_start(&buf), None);
}
#[test]
fn test_frame_decode() {
let data = [
// message
0x7e, 0x06, 0x08, 0x09, 0x03, 0x05, 0x06, 0x07, 0x7d, 0x5d,
0x7d, 0x5e, 0x7f, 0xff, 0xe6, 0x2d, 0x17, 0xc6, 0x7e,
// and trailing bytes
0x02, 0x01
];
let expect = Frame {
address: 0x010203,
control: 0x03,
data: vec![0x05, 0x06, 0x07, 0x7D, 0x7E, 0x7F, 0xFF].into(),
};
let mut dec = Decoder::new();
// test standard decoding
let mut buf = BytesMut::from(&data[..data.len()-2]);
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 0);
// test decoding with trailing bytes
let mut buf = BytesMut::from(&data[..data.len()]);
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 2);
assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedData));
assert_eq!(buf.remaining(), 0);
// test partial decoding / re-entrancy
let mut buf = BytesMut::from(&data[..9]);
assert_eq!(dec.process(&mut buf), Ok(None));
assert_eq!(buf.remaining(), 0);
assert_eq!(dec.state, (State::Frame, EscState::Escape));
let mut buf = BytesMut::from(&data[9..data.len()-2]);
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 0);
// test decoding of subsequent frames
let mut buf = BytesMut::new();
buf.put_slice(&data[..data.len()-2]);
buf.put_slice(&data[..]);
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), data.len());
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 2);
// test decoding of cut-off frame / data loss (with frame being too small)
let mut buf = BytesMut::new();
buf.put_slice(&data[..5]);
buf.put_slice(&data[..]);
assert_eq!(dec.process(&mut buf), Err(Error::InvalidFrame));
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 2);
// test decoding of cut-off frame / data loss (with data being cut off)
let mut buf = BytesMut::new();
buf.put_slice(&data[..10]);
buf.put_slice(&data[..]);
assert_eq!(dec.process(&mut buf), Err(Error::InvalidChecksum));
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 2);
// test frame flag as escaped byte
let mut buf = BytesMut::from(&data[..10]);
buf.put_slice(&data[..]);
buf[9] = consts::flags::FRAME;
assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedEndOfFrame));
assert_eq!(dec.process(&mut buf), Err(Error::UnexpectedData));
assert_eq!(dec.process(&mut buf), Ok(Some(expect.clone())));
assert_eq!(buf.remaining(), 2);
}
}

View File

@@ -0,0 +1,146 @@
use bytes::{BufMut, BytesMut};
use super::{consts, crc::Crc32, varint, Frame};
struct ByteEscape<B: BufMut> {
buf: B,
}
impl<B: BufMut> ByteEscape<B> {
fn new(buf: B) -> Self {
Self { buf }
}
fn put_u8(&mut self, byte: u8) {
match byte {
consts::flags::ESCAPE | consts::flags::FRAME => self.buf.put_slice(&[
consts::flags::ESCAPE,
consts::escape::MASK ^ byte
]),
_ => self.buf.put_u8(byte),
}
}
fn put_frame_flag(&mut self) {
self.buf.put_u8(super::consts::flags::FRAME)
}
}
impl ByteEscape<&mut BytesMut> {
fn reserve(&mut self, additional: usize) -> &mut Self {
self.buf.reserve(additional);
self
}
}
struct Encoder<B: BufMut> {
buf: ByteEscape<B>,
crc: Crc32,
}
impl<B: BufMut> Encoder<B> {
fn new(buf: B) -> Self {
Self {
buf: ByteEscape::new(buf),
crc: Crc32::new(),
}
}
fn flag(&mut self) -> &mut Self {
self.buf.put_frame_flag();
self
}
fn put_u8(&mut self, byte: u8) -> &mut Self {
self.crc.put_u8(byte);
self.buf.put_u8(byte);
self
}
fn put_bytes<T: IntoIterator<Item = u8>>(&mut self, bytes: T) -> &mut Self {
for b in bytes.into_iter() {
self.put_u8(b);
}
self
}
fn finalize(&mut self) {
self.put_bytes(self.crc.value().to_le_bytes());
self.flag();
}
}
impl Encoder<&mut BytesMut> {
fn reserve(&mut self, additional: usize) -> &mut Self {
self.buf.reserve(additional);
self
}
}
pub fn encode(buf: &mut BytesMut, frame: &Frame) {
Encoder::new(buf)
.reserve(frame.data.len() + 8) // reserve at least data-size + min-frame-size
.flag() // flag
.put_bytes(varint::encode(frame.address)) // address
.put_u8(frame.control) // control
.put_bytes(frame.data.iter().copied()) // data
.reserve(5) // reserve CRC32 + flag
.finalize() // checksum and flag
}
pub fn encode_bytes(frame: &Frame) -> BytesMut {
let mut buf = BytesMut::new();
encode(&mut buf, frame);
buf
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_escape_bytes() {
fn e(src: &[u8]) -> Vec<u8> {
let mut dst = Vec::new();
let mut buf = ByteEscape::new(&mut dst);
for byte in src {
buf.put_u8(*byte);
}
dst
}
assert_eq!(e(&[0x00, 0x00]), [0x00, 0x00]);
assert_eq!(e(&[0x7D]), [0x7D, 0x5D]);
assert_eq!(e(&[0x7E]), [0x7D, 0x5E]);
assert_eq!(e(&[0x01, 0x7D, 0x02]), [0x01, 0x7D, 0x5D, 0x02]);
assert_eq!(e(&[0x01, 0x7E, 0x02]), [0x01, 0x7D, 0x5E, 0x02]);
assert_eq!(e(&[0x7D, 0x7E]), [0x7D, 0x5D, 0x7D, 0x5E]);
assert_eq!(e(&[0x7F, 0x5D, 0x7E]), [0x7F, 0x5D, 0x7D, 0x5E]);
}
#[test]
fn test_encode() {
assert_eq!([
0x7e, 0x06, 0x08, 0x09, 0x03, 0x8b, 0x3b, 0xf7, 0x42, 0x7e,
], &encode_bytes(&Frame {
address: 0x010203,
control: 0x03,
data: vec![].into(),
})[..]);
assert_eq!([
0x7e, 0x06, 0x08, 0x09, 0x03, 0x05, 0x06, 0x07, 0x7d, 0x5d,
0x7d, 0x5e, 0x7f, 0xff, 0xe6, 0x2d, 0x17, 0xc6, 0x7e,
], &encode_bytes(&Frame {
address: 0x010203,
control: 0x03,
data: vec![0x05, 0x06, 0x07, 0x7d, 0x7e, 0x7f, 0xff].into(),
})[..]);
}
}

View File

@@ -0,0 +1,34 @@
//! High-level Data Link Control (HDLC) support library.
pub mod codec;
pub mod consts;
pub mod crc;
pub mod decoder;
pub mod encoder;
pub mod varint;
pub use codec::Codec;
use bytes::BytesMut;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub address: u32,
pub control: u8,
pub data: Box<[u8]>,
}
impl Frame {
pub fn decode(buf: &mut BytesMut) -> Result<Option<Self>, decoder::Error> {
decoder::Decoder::new().process(buf)
}
pub fn encode(&self, buf: &mut BytesMut) {
encoder::encode(buf, self)
}
pub fn encode_bytes(&self) -> BytesMut {
encoder::encode_bytes(self)
}
}

View File

@@ -0,0 +1,154 @@
//! Support for variable length integer encoding as used in the HDLC frame encoding.
use arrayvec::ArrayVec;
pub fn decode<'a, S: IntoIterator<Item = &'a u8>>(src: S) -> Result<(u32, usize), DecodeError> {
let mut address = 0;
for (i, b) in src.into_iter().copied().enumerate() {
address |= ((b >> 1) as u64) << (i * 7);
if address > u32::MAX as u64 {
Err(DecodeError::Overflow)?;
}
if b & 0x01 == 0x01 {
return Ok((address as u32, i + 1));
}
}
Err(DecodeError::Incomplete)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeError {
Incomplete,
Overflow,
}
pub fn encode(num: u32) -> Encode {
Encode { num, done: false }
}
pub fn encode_vec(num: u32) -> ArrayVec<u8, { num_bytes(u32::MAX) }> {
encode(num).collect()
}
pub struct Encode {
num: u32,
done: bool,
}
impl Iterator for Encode {
type Item = u8;
fn next(&mut self) -> Option<u8> {
if (self.num >> 7) != 0 {
let b = ((self.num & 0x7F) as u8) << 1;
self.num >>= 7;
Some(b)
} else if !self.done {
let b = (((self.num & 0x7F) as u8) << 1) | 1;
self.done = true;
Some(b)
} else {
None
}
}
}
pub const fn num_bytes(value: u32) -> usize {
if value == 0 {
1
} else {
(u32::BITS - value.leading_zeros()).div_ceil(7) as _
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_decode() {
assert_eq!(decode(&[0x01]).unwrap(), (0x00, 1));
assert_eq!(decode(&[0x00, 0x00, 0x00, 0x01]).unwrap(), (0x00, 4));
assert_eq!(decode(&[0x11, 0x00]).unwrap(), (0x0008, 1));
assert_eq!(decode(&[0x10, 0x21]).unwrap(), (0x0808, 2));
assert_eq!(decode(&[0x01]).unwrap(), (0x00, 1));
assert_eq!(decode(&[0x03]).unwrap(), (0x01, 1));
assert_eq!(decode(&[0xff]).unwrap(), (0x7f, 1));
assert_eq!(decode(&[0x00, 0x03]).unwrap(), (0x80, 2));
assert_eq!(decode(&[0xfe, 0xff]).unwrap(), (0x3fff, 2));
assert_eq!(decode(&[0x00, 0x00, 0x03]).unwrap(), (0x4000, 3));
assert_eq!(decode(&[0xfe, 0xfe, 0xff]).unwrap(), (0x1f_ffff, 3));
assert_eq!(decode(&[0x00, 0x00, 0x00, 0x03]).unwrap(), (0x20_0000, 4));
assert_eq!(decode(&[0xfe, 0xfe, 0xfe, 0xff]).unwrap(), (0x0fff_ffff, 4));
assert_eq!(decode(&[0x00, 0x00, 0x00, 0x00, 0x03]).unwrap(), (0x1000_0000, 5));
assert_eq!(decode(&[0xfe, 0x03]).unwrap(), (u8::MAX as _, 2));
assert_eq!(decode(&[0xfe, 0xfe, 0x07]).unwrap(), (u16::MAX as _, 3));
assert_eq!(decode(&[0xfe, 0xfe, 0xfe, 0xfe, 0x1f]).unwrap(), (u32::MAX, 5));
assert_eq!(decode(&[0xFE]), Err(DecodeError::Incomplete));
assert_eq!(decode(&[0xFE, 0xFE, 0xFE, 0xFE, 0xFF]), Err(DecodeError::Overflow));
}
#[test]
fn test_encode() {
assert_eq!(encode_vec(0x01234)[..], [0x68, 0x49]);
assert_eq!(encode_vec(0x87654)[..], [0xa8, 0xd8, 0x43]);
assert_eq!(encode_vec(0x00)[..], [0x01]);
assert_eq!(encode_vec(0x01)[..], [0x03]);
assert_eq!(encode_vec(0x7f)[..], [0xff]);
assert_eq!(encode_vec(0x80)[..], [0x00, 0x03]);
assert_eq!(encode_vec(0x3fff)[..], [0xfe, 0xff]);
assert_eq!(encode_vec(0x4000)[..], [0x00, 0x00, 0x03]);
assert_eq!(encode_vec(0x1f_ffff)[..], [0xfe, 0xfe, 0xff]);
assert_eq!(encode_vec(0x20_0000)[..], [0x00, 0x00, 0x00, 0x03]);
assert_eq!(encode_vec(0x0fff_ffff)[..], [0xfe, 0xfe, 0xfe, 0xff]);
assert_eq!(encode_vec(0x1000_0000)[..], [0x00, 0x00, 0x00, 0x00, 0x03]);
assert_eq!(encode_vec(u8::MAX as _)[..], [0xfe, 0x03]);
assert_eq!(encode_vec(u16::MAX as _)[..], [0xfe, 0xfe, 0x07]);
assert_eq!(encode_vec(u32::MAX)[..], [0xfe, 0xfe, 0xfe, 0xfe, 0x1f]);
}
#[test]
fn test_num_bytes() {
assert_eq!(num_bytes(0x00), 1);
assert_eq!(num_bytes(0x01), 1);
assert_eq!(num_bytes(0x7f), 1);
assert_eq!(num_bytes(0x80), 2);
assert_eq!(num_bytes(0x3fff), 2);
assert_eq!(num_bytes(0x4000), 3);
assert_eq!(num_bytes(0x1f_ffff), 3);
assert_eq!(num_bytes(0x20_0000), 4);
assert_eq!(num_bytes(0x0fff_ffff), 4);
assert_eq!(num_bytes(0x1000_0000), 5);
assert_eq!(num_bytes(u8::MAX as _), 2);
assert_eq!(num_bytes(u16::MAX as _), 3);
assert_eq!(num_bytes(u32::MAX), 5);
}
}

15
libmaestro/src/lib.rs Normal file
View File

@@ -0,0 +1,15 @@
//! Library for the Maestro protocol used to change settings (ANC, equalizer,
//! etc.) on the Google Pixel Buds Pro. Might support other Pixel Buds, might
//! not.
use uuid::{uuid, Uuid};
/// UUID under which the Maestro protocol is advertised.
///
/// Defined as `25e97ff7-24ce-4c4c-8951-f764a708f7b5`.
pub const UUID: Uuid = uuid!("25e97ff7-24ce-4c4c-8951-f764a708f7b5");
pub mod hdlc;
pub mod protocol;
pub mod pwrpc;
pub mod service;

View File

@@ -0,0 +1,115 @@
use num_enum::{FromPrimitive, IntoPrimitive};
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, FromPrimitive, IntoPrimitive)]
pub enum Peer {
Unknown = 0,
Host = 1,
Case = 2,
LeftBtCore = 3,
RightBtCore = 4,
LeftSensorHub = 5,
RightSensorHub = 6,
LeftSpiBridge = 7,
RightSpiBridge = 8,
DebugApp = 9,
MaestroA = 10,
LeftTahiti = 11,
RightTahiti = 12,
MaestroB = 13,
#[num_enum(catch_all)]
Unrecognized(u8) = 0xff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Address {
value: u32,
}
impl Address {
pub fn from_value(value: u32) -> Self {
Address { value }
}
pub fn from_peers(source: Peer, target: Peer) -> Self {
let source: u8 = source.into();
let target: u8 = target.into();
Self::from_value(((source as u32 & 0xf) << 6) | ((target as u32 & 0xf) << 10))
}
pub fn value(&self) -> u32 {
self.value
}
pub fn source(&self) -> Peer {
Peer::from_primitive(((self.value >> 6) & 0x0f) as u8)
}
pub fn target(&self) -> Peer {
Peer::from_primitive(((self.value >> 10) & 0x0f) as u8)
}
pub fn swap(&self) -> Self {
Self::from_peers(self.target(), self.source())
}
pub fn channel_id(&self) -> Option<u32> {
let source = self.source();
let target = self.target();
if source == Peer::MaestroA || source == Peer::MaestroB {
channel_id(source, target)
} else {
channel_id(target, source)
}
}
}
impl From<u32> for Address {
fn from(value: u32) -> Self {
Self::from_value(value)
}
}
impl From<(Peer, Peer)> for Address {
fn from(peers: (Peer, Peer)) -> Self {
Self::from_peers(peers.0, peers.1)
}
}
pub fn channel_id(local: Peer, remote: Peer) -> Option<u32> {
match (local, remote) {
(Peer::MaestroA, Peer::Case) => Some(18),
(Peer::MaestroA, Peer::LeftBtCore) => Some(19),
(Peer::MaestroA, Peer::LeftSensorHub) => Some(20),
(Peer::MaestroA, Peer::RightBtCore) => Some(21),
(Peer::MaestroA, Peer::RightSensorHub) => Some(22),
(Peer::MaestroB, Peer::Case) => Some(23),
(Peer::MaestroB, Peer::LeftBtCore) => Some(24),
(Peer::MaestroB, Peer::LeftSensorHub) => Some(25),
(Peer::MaestroB, Peer::RightBtCore) => Some(26),
(Peer::MaestroB, Peer::RightSensorHub) => Some(27),
(_, _) => None,
}
}
pub fn address_for_channel(channel: u32) -> Option<Address> {
match channel {
18 => Some(Address::from_peers(Peer::MaestroA, Peer::Case)),
19 => Some(Address::from_peers(Peer::MaestroA, Peer::LeftBtCore)),
20 => Some(Address::from_peers(Peer::MaestroA, Peer::LeftSensorHub)),
21 => Some(Address::from_peers(Peer::MaestroA, Peer::RightBtCore)),
22 => Some(Address::from_peers(Peer::MaestroA, Peer::RightSensorHub)),
23 => Some(Address::from_peers(Peer::MaestroB, Peer::Case)),
24 => Some(Address::from_peers(Peer::MaestroB, Peer::LeftBtCore)),
25 => Some(Address::from_peers(Peer::MaestroB, Peer::LeftSensorHub)),
26 => Some(Address::from_peers(Peer::MaestroB, Peer::RightBtCore)),
27 => Some(Address::from_peers(Peer::MaestroB, Peer::RightSensorHub)),
_ => None,
}
}

View File

@@ -0,0 +1,81 @@
use bytes::BytesMut;
use prost::Message;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_util::codec::{Decoder, Framed, Encoder};
use crate::pwrpc::types::RpcPacket;
use crate::hdlc;
use super::addr;
pub struct Codec {
hdlc: hdlc::Codec,
}
impl Codec {
pub fn new() -> Self {
Self {
hdlc: hdlc::Codec::new(),
}
}
pub fn wrap<T>(self, io: T) -> Framed<T, Codec>
where
T: AsyncRead + AsyncWrite,
{
Framed::with_capacity(io, self, 4096 as _)
}
}
impl Default for Codec {
fn default() -> Self {
Self::new()
}
}
impl Decoder for Codec {
type Item = RpcPacket;
type Error = std::io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
match self.hdlc.decode(src)? {
Some(frame) => {
if frame.control != 0x03 {
tracing::warn!("unexpected control type: {}", frame.control);
return Ok(None);
}
let packet = RpcPacket::decode(&frame.data[..])?;
Ok(Some(packet))
}
None => Ok(None),
}
}
}
impl Encoder<&RpcPacket> for Codec {
type Error = std::io::Error;
fn encode(&mut self, packet: &RpcPacket, dst: &mut BytesMut) -> Result<(), Self::Error> {
let address = addr::address_for_channel(packet.channel_id).unwrap();
let frame = hdlc::Frame {
address: address.value(),
control: 0x03,
data: packet.encode_to_vec().into(), // TODO: can we avoid these allocations?
};
self.hdlc.encode(&frame, dst)
}
}
impl Encoder<RpcPacket> for Codec {
type Error = std::io::Error;
fn encode(&mut self, packet: RpcPacket, dst: &mut BytesMut) -> Result<(), Self::Error> {
self.encode(&packet, dst)
}
}

View File

@@ -0,0 +1,7 @@
pub mod addr;
pub mod codec;
pub mod utils;
pub mod types {
include!(concat!(env!("OUT_DIR"), "/maestro_pw.rs"));
}

View File

@@ -0,0 +1,73 @@
use crate::pwrpc::Error;
use crate::pwrpc::client::{Client, Request, UnaryResponse, ClientHandle};
use crate::pwrpc::id::PathRef;
use crate::pwrpc::types::RpcPacket;
use super::addr;
use super::addr::Peer;
use super::types::SoftwareInfo;
pub async fn resolve_channel<S, E>(client: &mut Client<S>) -> Result<u32, Error>
where
S: futures::Sink<RpcPacket>,
S: futures::Stream<Item = Result<RpcPacket, E>> + Unpin,
Error: From<E>,
Error: From<S::Error>,
{
tracing::trace!("resolving channel");
let channels = (
addr::channel_id(Peer::MaestroA, Peer::Case).unwrap(),
addr::channel_id(Peer::MaestroA, Peer::LeftBtCore).unwrap(),
addr::channel_id(Peer::MaestroA, Peer::RightBtCore).unwrap(),
addr::channel_id(Peer::MaestroB, Peer::Case).unwrap(),
addr::channel_id(Peer::MaestroB, Peer::LeftBtCore).unwrap(),
addr::channel_id(Peer::MaestroB, Peer::RightBtCore).unwrap(),
);
let tasks = (
try_open_channel(client.handle(), channels.0),
try_open_channel(client.handle(), channels.1),
try_open_channel(client.handle(), channels.2),
try_open_channel(client.handle(), channels.3),
try_open_channel(client.handle(), channels.4),
try_open_channel(client.handle(), channels.5),
);
let channel = tokio::select! {
// Ensure that the open() calls are registered before we start running
// the client.
biased;
res = tasks.0 => { res? },
res = tasks.1 => { res? },
res = tasks.2 => { res? },
res = tasks.3 => { res? },
res = tasks.4 => { res? },
res = tasks.5 => { res? },
res = client.run() => { res?; return Err(Error::aborted("client terminated")) }
};
tracing::trace!(channel=channel, "channel resolved");
Ok(channel)
}
async fn try_open_channel(mut handle: ClientHandle, channel_id: u32) -> Result<u32, Error> {
let path = PathRef::new("maestro_pw.Maestro/GetSoftwareInfo");
let service_id = path.service().hash();
let method_id = path.method().hash();
let req = Request {
channel_id,
service_id,
method_id,
call_id: 0xffffffff,
message: (),
};
let mut rsp: UnaryResponse<SoftwareInfo> = handle.open_unary(req)?;
rsp.result().await?;
Ok(channel_id)
}

View File

@@ -0,0 +1,960 @@
use std::pin::Pin;
use std::task::Poll;
use futures::{Sink, SinkExt, Stream, StreamExt};
use futures::channel::mpsc;
use futures::stream::{SplitSink, SplitStream, FusedStream};
use prost::Message;
use super::id::Path;
use super::status::{Status, Error};
use super::types::{RpcType, RpcPacket, PacketType};
#[derive(Debug)]
pub struct Client<S> {
/// Stream for lower-level transport.
io_rx: SplitStream<S>,
/// Sink for lower-level transport.
io_tx: SplitSink<S, RpcPacket>,
/// Queue receiver for requests to be processed and sent by us.
queue_rx: mpsc::UnboundedReceiver<CallRequest>,
/// Queue sender for requests to be processed by us. Counter-part for
/// `queue_rx`, used by callers via `ClientHandle` to initiate new calls.
queue_tx: mpsc::UnboundedSender<CallRequest>,
/// Pending RPC calls, waiting for a response.
pending: Vec<Call>,
}
impl<S, E> Client<S>
where
S: Sink<RpcPacket>,
S: Stream<Item = Result<RpcPacket, E>> + Unpin,
Error: From<S::Error>,
Error: From<E>,
{
pub fn new(stream: S) -> Client<S> {
let (io_tx, io_rx) = stream.split();
let (queue_tx, queue_rx) = mpsc::unbounded();
Client {
io_rx,
io_tx,
queue_rx,
queue_tx,
pending: Vec::new(),
}
}
pub fn handle(&self) -> ClientHandle {
ClientHandle {
queue_tx: self.queue_tx.clone(),
}
}
pub async fn run(&mut self) -> Result<(), Error> {
// Process the request queue first in case we are trying to catch some
// early RPC responses via open() calls.
while let Ok(Some(request)) = self.queue_rx.try_next() {
self.process_request(request).await?;
}
loop {
tokio::select! {
packet = self.io_rx.next() => {
let packet = packet
.ok_or_else(|| Error::aborted("underlying IO stream closed"))??;
self.process_packet(packet).await?;
},
request = self.queue_rx.next() => {
// SAFETY: We hold both sender and receiver parts and are
// the only ones allowed to close this queue. Therefore, it
// will always be open here.
let request = request.expect("request queue closed unexpectedly");
self.process_request(request).await?;
},
}
}
}
pub async fn terminate(&mut self) -> Result<(), Error> {
tracing::trace!("terminating client");
// Collect messages to be sent instead of directly sending them. We
// process infallible (local) operations first, before we try to
// communicate with the RPC peer, which is fallible.
let mut send = Vec::new();
// Close our request queue.
self.queue_rx.close();
// Process all pending requests. Abort requests for new calls and
// send/forward any errors.
//
// SAFETY: try_next() can only return an error when the channel has not
// been closed yet.
while let Some(msg) = self.queue_rx.try_next().unwrap() {
match msg {
CallRequest::New { sender, .. } => {
// Drop new requests. Instead, notify caller with status 'aborted'.
let update = CallUpdate::Error { status: Status::Aborted };
let _ = sender.unbounded_send(update);
sender.close_channel();
},
CallRequest::Error { uid, code, tx } => {
// Process error requests as normal: Send error message to
// peer, remove and complete call.
if let Some(mut call) = self.find_and_remove_call(uid) {
call.complete_with_error(code).await;
if tx {
send.push((uid, code));
}
}
},
}
}
// Cancel all pending RPCs and remove them from the list.
for call in &mut self.pending {
call.complete_with_error(Status::Aborted).await;
send.push((call.uid, Status::Cancelled));
}
self.pending.clear();
// Define functions because async try-catch blocks aren't a thing yet...
async fn do_send<S, E>(client: &mut Client<S>, send: Vec<(CallUid, Status)>) -> Result<(), Error>
where
S: Sink<RpcPacket>,
S: Stream<Item = Result<RpcPacket, E>> + Unpin,
Error: From<S::Error>,
Error: From<E>,
{
for (uid, code) in send {
client.send_client_error(uid, code).await?;
}
Ok(())
}
async fn do_close<S, E>(client: &mut Client<S>) -> Result<(), Error>
where
S: Sink<RpcPacket>,
S: Stream<Item = Result<RpcPacket, E>> + Unpin,
Error: From<S::Error>,
Error: From<E>,
{
client.io_tx.close().await?;
Ok(())
}
// Try to send cancel/error messages.
let res_send = do_send(self, send).await;
// Try to close the transport.
let res_close = do_close(self).await;
// Return the first error.
res_send?;
res_close
}
async fn process_packet(&mut self, packet: RpcPacket) -> Result<(), Error> {
tracing::trace!(
"received packet: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
let ty = packet.r#type;
let ty = PacketType::try_from(ty);
match ty {
Ok(PacketType::Response) => {
self.rpc_complete(packet).await
},
Ok(PacketType::ServerError) => {
self.rpc_complete_with_error(packet).await
},
Ok(PacketType::ServerStream) => {
self.rpc_stream_push(packet).await?
},
Ok(_) => {
tracing::error!(
"unsupported packet type: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
},
Err(_) => {
tracing::error!(
"unknown packet type: type=0x{:02x}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.r#type, packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
},
}
Ok(())
}
async fn rpc_complete(&mut self, packet: RpcPacket) {
let uid = CallUid::from_packet(&packet);
let call = self.find_and_remove_call(uid);
match call {
Some(mut call) => { // pending call found, complete rpc
tracing::trace!(
"completing rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
if packet.status != 0 {
tracing::warn!(
"completing rpc with non-zero status: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status
);
}
let status = Status::from(packet.status);
call.complete(packet.payload, status).await;
},
None => { // no pending call found, silently drop packet
tracing::debug!(
"received response for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
},
}
}
async fn rpc_complete_with_error(&mut self, packet: RpcPacket) {
let uid = CallUid::from_packet(&packet);
let call = self.find_and_remove_call(uid);
match call {
Some(mut call) => { // pending call found, complete rpc with error
tracing::trace!(
"completing rpc with error: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status
);
let status = Status::from(packet.status);
call.complete_with_error(status).await;
},
None => { // no pending call found, silently drop packet
tracing::debug!(
"received error for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, status={}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id, packet.status
);
},
}
}
async fn rpc_stream_push(&mut self, packet: RpcPacket) -> Result<(), Error> {
let uid = CallUid::from_packet(&packet);
let call = self.find_call_mut(uid);
match call {
Some(call) => { // pending call found, forward packet to caller
tracing::trace!(
"pushing server stream packet to caller: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
if call.ty.has_server_stream() { // packet was expected, forward it
call.push_item(packet.payload).await;
} else { // this type of rpc doesn't expect streaming packets from the server
// SAFETY: We are the only ones that can add, remove, or
// otherwise modify items in-between the above find
// operation and this one as we have the lock.
let mut call = self.find_and_remove_call(uid).unwrap();
tracing::warn!(
"received stream packet for non-stream rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.channel_id, packet.service_id, packet.method_id, packet.call_id
);
call.complete_with_error(Status::InvalidArgument).await;
self.send_client_error(uid, Status::InvalidArgument).await?;
}
},
None => { // no pending call found, try to notify server
tracing::debug!(
"received stream packet for non-pending rpc: service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
packet.service_id, packet.method_id, packet.call_id
);
self.send_client_error(uid, Status::FailedPrecondition).await?;
},
}
Ok(())
}
async fn process_request(&mut self, request: CallRequest) -> Result<(), Error> {
match request {
CallRequest::New { ty, uid, payload, sender, tx } => {
let call = Call { ty, uid, sender };
let packet = RpcPacket {
r#type: PacketType::Request.into(),
channel_id: uid.channel,
service_id: uid.service,
method_id: uid.method,
payload,
status: Status::Ok as _,
call_id: uid.call,
};
let action = if tx { "starting" } else { "opening" };
tracing::trace!(
"{} rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
action, packet.channel_id, packet.service_id, packet.method_id, packet.call_id,
);
self.pending.push(call);
if tx {
self.send(packet).await?;
}
Ok(())
},
CallRequest::Error { uid, code, tx } => {
match self.find_and_remove_call(uid) {
Some(mut call) => {
tracing::trace!(
"cancelling active rpc with code: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, code={}",
uid.channel, uid.service, uid.method, uid.call, code as u32,
);
call.complete_with_error(code).await;
if tx {
self.send_client_error(uid, code).await?;
}
Ok(())
},
None => {
tracing::trace!(
"received error request for non-pending rpc: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, code={}",
uid.channel, uid.service, uid.method, uid.call, code as u32,
);
Ok(())
},
}
},
}
}
fn find_and_remove_call(&mut self, uid: CallUid) -> Option<Call> {
let index = self.pending.iter().position(|call| call.uid == uid);
match index {
Some(index) => Some(self.pending.remove(index)),
None => None,
}
}
fn find_call_mut(&mut self, uid: CallUid) -> Option<&mut Call> {
self.pending.iter_mut().find(|call| call.uid == uid)
}
async fn send_client_error(&mut self, uid: CallUid, status: Status) -> Result<(), Error> {
let status: u32 = status.into();
tracing::trace!(
"sending client error packet: status={}, channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}",
status, uid.channel, uid.service, uid.method, uid.call,
);
let error_packet = RpcPacket {
r#type: PacketType::ClientError as _,
channel_id: uid.channel,
service_id: uid.service,
method_id: uid.method,
call_id: uid.call,
payload: Vec::new(),
status,
};
self.send(error_packet).await
}
async fn send(&mut self, packet: RpcPacket) -> Result<(), Error> {
self.io_tx.send(packet).await?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ClientHandle {
queue_tx: mpsc::UnboundedSender<CallRequest>,
}
impl ClientHandle {
pub fn call_unary<M1, M2>(&mut self, request: Request<M1>) -> Result<UnaryResponse<M2>, Error>
where
M1: Message,
M2: Message + Default,
{
let handle = self.call(RpcType::Unary, request)?;
let response = UnaryResponse {
maker: std::marker::PhantomData,
handle,
};
Ok(response)
}
pub fn call_server_stream<M1, M2>(&mut self, request: Request<M1>) -> Result<StreamResponse<M2>, Error>
where
M1: Message,
M2: Message + Default,
{
let handle = self.call(RpcType::ServerStream, request)?;
let stream = StreamResponse {
marker: std::marker::PhantomData,
handle,
};
Ok(stream)
}
fn call<M>(&mut self, ty: RpcType, request: Request<M>) -> Result<CallHandle, Error>
where
M: Message,
{
let (sender, receiver) = mpsc::unbounded();
let uid = CallUid {
channel: request.channel_id,
service: request.service_id,
method: request.method_id,
call: request.call_id,
};
let payload = request.message.encode_to_vec();
let queue_tx = self.queue_tx.clone();
let request = CallRequest::New { ty, uid, payload, sender, tx: true };
let handle = CallHandle { uid, queue_tx, receiver, cancel_on_drop: true };
self.queue_tx.unbounded_send(request)
.map_err(|_| Error::aborted("the channel has been closed, no new calls are allowed"))?;
Ok(handle)
}
pub fn open_unary<M>(&mut self, request: Request<()>) -> Result<UnaryResponse<M>, Error>
where
M: Message + Default,
{
let handle = self.open(RpcType::Unary, request)?;
let response = UnaryResponse {
maker: std::marker::PhantomData,
handle,
};
Ok(response)
}
pub fn open_server_stream<M>(&mut self, request: Request<()>) -> Result<StreamResponse<M>, Error>
where
M: Message + Default,
{
let handle = self.open(RpcType::ServerStream, request)?;
let stream = StreamResponse {
marker: std::marker::PhantomData,
handle,
};
Ok(stream)
}
fn open<M>(&mut self, ty: RpcType, request: Request<M>) -> Result<CallHandle, Error>
where
M: Message,
{
let (sender, receiver) = mpsc::unbounded();
let uid = CallUid {
channel: request.channel_id,
service: request.service_id,
method: request.method_id,
call: request.call_id,
};
let payload = Vec::new();
let queue_tx = self.queue_tx.clone();
let request = CallRequest::New { ty, uid, payload, sender, tx: false };
let handle = CallHandle { uid, queue_tx, receiver, cancel_on_drop: false };
self.queue_tx.unbounded_send(request)
.map_err(|_| Error::aborted("the channel has been closed, no new calls are allowed"))?;
Ok(handle)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CallUid {
channel: u32,
service: u32,
method: u32,
call: u32,
}
impl CallUid {
fn from_packet(packet: &RpcPacket) -> Self {
Self {
channel: packet.channel_id,
service: packet.service_id,
method: packet.method_id,
call: packet.call_id
}
}
}
#[derive(Debug)]
enum CallRequest {
New {
ty: RpcType,
uid: CallUid,
payload: Vec<u8>,
sender: mpsc::UnboundedSender<CallUpdate>,
tx: bool,
},
Error {
uid: CallUid,
code: Status,
tx: bool,
},
}
#[derive(Debug)]
enum CallUpdate {
Complete {
data: Vec<u8>,
status: Status,
},
StreamItem {
data: Vec<u8>,
},
Error {
status: Status,
}
}
#[derive(Debug)]
struct Call {
ty: RpcType,
uid: CallUid,
sender: mpsc::UnboundedSender<CallUpdate>,
}
impl Call {
pub async fn complete(&mut self, payload: Vec<u8>, status: Status) {
let update = CallUpdate::Complete { data: payload, status };
self.push_update(update).await;
self.sender.close_channel();
}
pub async fn complete_with_error(&mut self, status: Status) {
let update = CallUpdate::Error { status };
self.push_update(update).await;
self.sender.close_channel();
}
pub async fn push_item(&mut self, payload: Vec<u8>) {
let update = CallUpdate::StreamItem { data: payload };
self.push_update(update).await;
}
async fn push_update(&mut self, update: CallUpdate) {
if let Err(e) = self.sender.unbounded_send(update) {
let update = e.into_inner();
match update {
CallUpdate::Complete { .. } => {
tracing::warn!(
"cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=complete",
self.uid.channel, self.uid.service, self.uid.method, self.uid.call,
)
},
CallUpdate::StreamItem { .. } => {
tracing::warn!(
"cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=stream",
self.uid.channel, self.uid.service, self.uid.method, self.uid.call,
)
},
CallUpdate::Error { status } => {
let code: u32 = status.into();
tracing::trace!(
"cannot send call update, caller is gone: channel_id=0x{:02x}, service_id=0x{:08x}, method_id=0x{:08x}, call_id=0x{:02x}, update=error, error={}",
self.uid.channel, self.uid.service, self.uid.method, self.uid.call, code,
)
},
}
}
}
}
impl Drop for Call {
fn drop(&mut self) {
// Notify caller that call has been aborted if the call has not been
// completed yet. Ignore errors.
if !self.sender.is_closed() {
let update = CallUpdate::Error { status: Status::Aborted };
let _ = self.sender.unbounded_send(update);
self.sender.close_channel();
}
}
}
struct CallHandle {
uid: CallUid,
queue_tx: mpsc::UnboundedSender<CallRequest>,
receiver: mpsc::UnboundedReceiver<CallUpdate>,
cancel_on_drop: bool,
}
impl CallHandle {
fn is_complete(&self) -> bool {
self.queue_tx.is_closed()
}
fn error(&mut self, code: Status, tx: bool) -> bool {
let request = CallRequest::Error { uid: self.uid, code, tx };
let ok = self.queue_tx.unbounded_send(request).is_ok();
// Sending an error will complete the RPC. Disconnect our queue end to
// prevent more errors/cancel-requests to be sent.
self.queue_tx.disconnect();
ok
}
fn abandon(&mut self) -> bool {
self.error(Status::Cancelled, false)
}
fn cancel_on_drop(&mut self, cancel: bool) {
self.cancel_on_drop = cancel
}
fn cancel(&mut self) -> bool {
self.error(Status::Cancelled, true)
}
async fn cancel_and_wait(&mut self) -> Result<(), Error> {
if !self.cancel() {
return Ok(())
}
loop {
match self.receiver.next().await {
Some(CallUpdate::StreamItem { .. }) => {
continue
},
Some(CallUpdate::Complete { .. }) => {
return Ok(())
},
Some(CallUpdate::Error { status: Status::Cancelled }) => {
return Ok(())
},
Some(CallUpdate::Error { status }) => {
return Err(Error::from(status))
},
None => {
return Ok(())
},
}
}
}
}
impl Drop for CallHandle {
fn drop(&mut self) {
if self.cancel_on_drop {
self.cancel();
} else {
self.abandon();
}
}
}
pub struct Request<M> {
pub channel_id: u32,
pub service_id: u32,
pub method_id: u32,
pub call_id: u32,
pub message: M,
}
pub struct UnaryResponse<M> {
maker: std::marker::PhantomData<M>,
handle: CallHandle,
}
impl<M> UnaryResponse<M>
where
M: Message + Default,
{
pub async fn result(&mut self) -> Result<M, Error> {
let update = match self.handle.receiver.next().await {
Some(update) => update,
None => return Err(Error::resource_exhausted("cannot fetch result() multiple times")),
};
let data = match update {
CallUpdate::Complete { data, status: Status::Ok } => data,
CallUpdate::Complete { status, .. } => return Err(Error::from(status)),
CallUpdate::Error { status } => return Err(Error::from(status)),
CallUpdate::StreamItem { .. } => unreachable!("received stream update on unary rpc"),
};
self.handle.queue_tx.disconnect();
let message = M::decode(&data[..])?;
Ok(message)
}
pub fn abandon(&mut self) -> bool {
self.handle.abandon()
}
pub fn cancel_on_drop(&mut self, cacnel: bool) {
self.handle.cancel_on_drop(cacnel)
}
pub fn cancel(&mut self) -> bool {
self.handle.cancel()
}
pub async fn cancel_and_wait(&mut self) -> Result<(), Error> {
self.handle.cancel_and_wait().await
}
pub fn is_complete(&self) -> bool {
self.handle.is_complete()
}
}
pub struct StreamResponse<M> {
marker: std::marker::PhantomData<M>,
handle: CallHandle,
}
impl<M> StreamResponse<M>
where
M: Message + Default,
{
pub fn stream(&mut self) -> ServerStream<'_, M> {
ServerStream {
marker: std::marker::PhantomData,
handle: &mut self.handle,
}
}
pub fn abandon(&mut self) -> bool {
self.handle.abandon()
}
pub fn cancel_on_drop(&mut self, cacnel: bool) {
self.handle.cancel_on_drop(cacnel)
}
pub fn cancel(&mut self) -> bool {
self.handle.cancel()
}
pub async fn cancel_and_wait(&mut self) -> Result<(), Error> {
self.handle.cancel_and_wait().await
}
pub fn is_complete(&self) -> bool {
self.handle.is_complete()
}
}
pub struct ServerStream<'a, M> {
marker: std::marker::PhantomData<&'a mut M>,
handle: &'a mut CallHandle,
}
impl<M> Stream for ServerStream<'_, M>
where
M: Message + Default,
{
type Item = Result<M, Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Option<Self::Item>> {
let update = match Pin::new(&mut self.handle.receiver).poll_next(cx) {
Poll::Ready(Some(update)) => update,
Poll::Ready(None) => return Poll::Ready(None),
Poll::Pending => return Poll::Pending,
};
let data = match update {
CallUpdate::StreamItem { data } => {
data
},
CallUpdate::Complete { .. } => {
// This indicates the end of the stream. The payload
// should be empty.
self.handle.receiver.close();
self.handle.queue_tx.disconnect();
return Poll::Ready(None);
},
CallUpdate::Error { status } => {
self.handle.receiver.close();
self.handle.queue_tx.disconnect();
return Poll::Ready(Some(Err(Error::from(status))));
},
};
let result = match M::decode(&data[..]) {
Ok(message) => {
Ok(message)
},
Err(e) => {
self.handle.error(Status::InvalidArgument, true);
Err(e.into())
},
};
Poll::Ready(Some(result))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.handle.receiver.size_hint()
}
}
impl<M> FusedStream for ServerStream<'_, M>
where
M: Message + Default,
{
fn is_terminated(&self) -> bool {
self.handle.receiver.is_terminated()
}
}
#[derive(Debug, Clone)]
pub struct UnaryRpc<M1, M2> {
marker1: std::marker::PhantomData<M1>,
marker2: std::marker::PhantomData<M2>,
path: Path,
}
impl<M1, M2> UnaryRpc<M1, M2>
where
M1: Message,
M2: Message + Default,
{
pub fn new(path: impl Into<Path>) -> Self {
Self {
marker1: std::marker::PhantomData,
marker2: std::marker::PhantomData,
path: path.into(),
}
}
pub fn call(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32, message: M1)
-> Result<UnaryResponse<M2>, Error>
{
let req = Request {
channel_id,
service_id: self.path.service().hash(),
method_id: self.path.method().hash(),
call_id,
message,
};
handle.call_unary(req)
}
pub fn open(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32)
-> Result<UnaryResponse<M2>, Error>
{
let req = Request {
channel_id,
service_id: self.path.service().hash(),
method_id: self.path.method().hash(),
call_id,
message: (),
};
handle.open_unary(req)
}
}
#[derive(Debug, Clone)]
pub struct ServerStreamRpc<M1, M2> {
marker1: std::marker::PhantomData<M1>,
marker2: std::marker::PhantomData<M2>,
path: Path,
}
impl<M1, M2> ServerStreamRpc<M1, M2>
where
M1: Message,
M2: Message + Default,
{
pub fn new(path: impl Into<Path>) -> Self {
Self {
marker1: std::marker::PhantomData,
marker2: std::marker::PhantomData,
path: path.into(),
}
}
pub fn call(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32, message: M1)
-> Result<StreamResponse<M2>, Error>
{
let req = Request {
channel_id,
service_id: self.path.service().hash(),
method_id: self.path.method().hash(),
call_id,
message,
};
handle.call_server_stream(req)
}
pub fn open(&self, handle: &mut ClientHandle, channel_id: u32, call_id: u32)
-> Result<StreamResponse<M2>, Error>
{
let req = Request {
channel_id,
service_id: self.path.service().hash(),
method_id: self.path.method().hash(),
call_id,
message: (),
};
handle.open_server_stream(req)
}
}

194
libmaestro/src/pwrpc/id.rs Normal file
View File

@@ -0,0 +1,194 @@
pub type Hash = u32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Id {
name: String,
}
impl Id {
pub fn new(id: impl Into<String>) -> Self {
Self { name: id.into() }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn hash(&self) -> Hash {
hash::hash_65599(&self.name)
}
pub fn as_ref(&self) -> IdRef<'_> {
IdRef { name: &self.name }
}
}
impl<S> From<S> for Id
where
S: Into<String>
{
fn from(name: S) -> Self {
Id::new(name)
}
}
impl<'a> From<IdRef<'a>> for Id {
fn from(id: IdRef<'a>) -> Self {
Id::new(id.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IdRef<'a> {
name: &'a str,
}
impl<'a> IdRef<'a> {
pub fn new(name: &'a str) -> Self {
Self { name }
}
pub fn name(&self) -> &'a str {
self.name
}
pub fn hash(&self) -> Hash {
hash::hash_65599(self.name)
}
}
impl<'a> From<&'a str> for IdRef<'a> {
fn from(name: &'a str) -> Self {
IdRef::new(name)
}
}
impl<'a> From<&'a String> for IdRef<'a> {
fn from(name: &'a String) -> Self {
IdRef::new(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Path {
path: String,
split: usize,
}
impl Path {
pub fn new(path: impl Into<String>) -> Self {
let path = path.into();
let split = path.rfind('/').unwrap_or(0);
Path { path, split }
}
pub fn service(&self) -> IdRef<'_> {
IdRef::new(&self.path[..self.split])
}
pub fn method(&self) -> IdRef<'_> {
if self.split < self.path.len() {
IdRef::new(&self.path[self.split+1..])
} else {
IdRef::new(&self.path[0..0])
}
}
pub fn as_ref(&self) -> PathRef<'_> {
PathRef { path: &self.path, split: self.split }
}
}
impl From<&str> for Path {
fn from(name: &str) -> Self {
Path::new(name)
}
}
impl From<String> for Path {
fn from(name: String) -> Self {
Path::new(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathRef<'a> {
path: &'a str,
split: usize,
}
impl<'a> PathRef<'a> {
pub fn new(path: &'a str) -> Self {
let split = path.rfind('/').unwrap_or(0);
PathRef { path, split }
}
pub fn service(&self) -> IdRef<'a> {
IdRef::new(&self.path[..self.split])
}
pub fn method(&self) -> IdRef<'a> {
if self.split < self.path.len() {
IdRef::new(&self.path[self.split+1..])
} else {
IdRef::new(&self.path[0..0])
}
}
}
impl<'a> From<&'a str> for PathRef<'a> {
fn from(name: &'a str) -> Self {
PathRef::new(name)
}
}
mod hash {
const HASH_CONST: u32 = 65599;
pub fn hash_65599(id: &str) -> u32 {
let mut hash = id.len() as u32;
let mut coef = HASH_CONST;
for chr in id.chars() {
hash = hash.wrapping_add(coef.wrapping_mul(chr as u32));
coef = coef.wrapping_mul(HASH_CONST);
}
hash
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_known_id_hashes() {
assert_eq!(IdRef::new("maestro_pw.Maestro").hash(), 0x7ede71ea);
assert_eq!(IdRef::new("GetSoftwareInfo").hash(), 0x7199fa44);
assert_eq!(IdRef::new("SubscribeToSettingsChanges").hash(), 0x2821adf5);
}
#[test]
fn test_path() {
let pref = PathRef::new("maestro_pw.Maestro/GetSoftwareInfo");
assert_eq!(pref.service().name(), "maestro_pw.Maestro");
assert_eq!(pref.service().hash(), 0x7ede71ea);
assert_eq!(pref.method().name(), "GetSoftwareInfo");
assert_eq!(pref.method().hash(), 0x7199fa44);
let pref = PathRef::new("maestro_pw.Maestro/SubscribeToSettingsChanges");
assert_eq!(pref.service().name(), "maestro_pw.Maestro");
assert_eq!(pref.service().hash(), 0x7ede71ea);
assert_eq!(pref.method().name(), "SubscribeToSettingsChanges");
assert_eq!(pref.method().hash(), 0x2821adf5);
}
}

View File

@@ -0,0 +1,8 @@
pub mod client;
pub mod id;
pub mod types;
pub mod utils;
mod status;
pub use status::Error;
pub use status::Status;

View File

@@ -0,0 +1,236 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Status {
Ok = 0,
Cancelled = 1,
Unknown = 2,
InvalidArgument = 3,
DeadlineExceeded = 4,
NotFound = 5,
AlreadyExists = 6,
PermissionDenied = 7,
ResourceExhausted = 8,
FailedPrecondition = 9,
Aborted = 10,
OutOfRange = 11,
Unimplemented = 12,
Internal = 13,
Unavailable = 14,
DataLoss = 15,
Unauthenticated = 16,
}
impl Status {
pub fn description(&self) -> &'static str {
match self {
Status::Ok => "The operation completed successfully",
Status::Cancelled => "The operation was cancelled",
Status::Unknown => "Unknown error",
Status::InvalidArgument => "Client specified an invalid argument",
Status::DeadlineExceeded => "Deadline expired before operation could complete",
Status::NotFound => "Some requested entity was not found",
Status::AlreadyExists => "Some entity that we attempted to create already exists",
Status::PermissionDenied => "The caller does not have permission to execute the specified operation",
Status::ResourceExhausted => "Some resource has been exhausted",
Status::FailedPrecondition => "The system is not in a state required for the operation's execution",
Status::Aborted => "The operation was aborted",
Status::OutOfRange => "Operation was attempted past the valid range",
Status::Unimplemented => "Operation is not implemented or not supported",
Status::Internal => "Internal error",
Status::Unavailable => "The service is currently unavailable",
Status::DataLoss => "Unrecoverable data loss or corruption",
Status::Unauthenticated => "The request does not have valid authentication credentials",
}
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
impl From<u32> for Status {
fn from(value: u32) -> Self {
match value {
0 => Status::Ok,
1 => Status::Cancelled,
2 => Status::Unknown,
3 => Status::InvalidArgument,
4 => Status::DeadlineExceeded,
5 => Status::NotFound,
6 => Status::AlreadyExists,
7 => Status::PermissionDenied,
8 => Status::ResourceExhausted,
9 => Status::FailedPrecondition,
10 => Status::Aborted,
11 => Status::OutOfRange,
12 => Status::Unimplemented,
13 => Status::Internal,
14 => Status::Unavailable,
15 => Status::DataLoss,
16 => Status::Unauthenticated,
_ => Status::Unknown,
}
}
}
impl From<Status> for u32 {
fn from(value: Status) -> Self {
value as _
}
}
#[derive(Debug)]
pub struct Error {
code: Status,
message: String,
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
}
impl Error {
pub fn new(code: Status, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
source: None,
}
}
pub fn cancelled(message: impl Into<String>) -> Self {
Self::new(Status::Cancelled, message)
}
pub fn unknown(message: impl Into<String>) -> Self {
Self::new(Status::Unknown, message)
}
pub fn invalid_argument(message: impl Into<String>) -> Self {
Self::new(Status::InvalidArgument, message)
}
pub fn deadline_exceeded(message: impl Into<String>) -> Self {
Self::new(Status::DeadlineExceeded, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(Status::NotFound, message)
}
pub fn already_exists(message: impl Into<String>) -> Self {
Self::new(Status::AlreadyExists, message)
}
pub fn permission_denied(message: impl Into<String>) -> Self {
Self::new(Status::PermissionDenied, message)
}
pub fn resource_exhausted(message: impl Into<String>) -> Self {
Self::new(Status::ResourceExhausted, message)
}
pub fn failed_precondition(message: impl Into<String>) -> Self {
Self::new(Status::FailedPrecondition, message)
}
pub fn aborted(message: impl Into<String>) -> Self {
Self::new(Status::Aborted, message)
}
pub fn out_of_range(message: impl Into<String>) -> Self {
Self::new(Status::OutOfRange, message)
}
pub fn unimplemented(message: impl Into<String>) -> Self {
Self::new(Status::Unimplemented, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(Status::Internal, message)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(Status::Unavailable, message)
}
pub fn data_loss(message: impl Into<String>) -> Self {
Self::new(Status::DataLoss, message)
}
pub fn unauthenticated(message: impl Into<String>) -> Self {
Self::new(Status::Unauthenticated, message)
}
pub fn extend(
code: Status,
message: impl Into<String>,
error: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
) -> Self {
Self {
code,
message: message.into(),
source: Some(error.into()),
}
}
pub fn code(&self) -> Status {
self.code
}
pub fn message(&self) -> &str {
&self.message
}
}
impl From<Status> for Error {
fn from(code: Status) -> Self {
Self::new(code, code.description())
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
use std::io::ErrorKind;
let code = match err.kind() {
ErrorKind::BrokenPipe
| ErrorKind::WouldBlock
| ErrorKind::WriteZero
| ErrorKind::Interrupted => Status::Internal,
ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::NotConnected
| ErrorKind::AddrInUse
| ErrorKind::AddrNotAvailable => Status::Unavailable,
ErrorKind::AlreadyExists => Status::AlreadyExists,
ErrorKind::ConnectionAborted => Status::Aborted,
ErrorKind::InvalidData => Status::DataLoss,
ErrorKind::InvalidInput => Status::InvalidArgument,
ErrorKind::NotFound => Status::NotFound,
ErrorKind::PermissionDenied => Status::PermissionDenied,
ErrorKind::TimedOut => Status::DeadlineExceeded,
ErrorKind::UnexpectedEof => Status::OutOfRange,
_ => Status::Unknown,
};
Error::extend(code, err.to_string(), err)
}
}
impl From<prost::DecodeError> for Error {
fn from(error: prost::DecodeError) -> Self {
Self::extend(Status::InvalidArgument, "failed to decode message", error)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "status: {:?}, message: {:?}", self.code, self.message)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source.as_ref().map(|err| (&**err) as _)
}
}

View File

@@ -0,0 +1,31 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RpcType {
Unary,
ServerStream,
ClientStream,
BidirectionalStream,
}
impl RpcType {
pub fn has_server_stream(&self) -> bool {
match *self {
RpcType::ServerStream | RpcType::BidirectionalStream => true,
RpcType::Unary | RpcType::ClientStream => false,
}
}
pub fn has_client_stream(&self) -> bool {
match *self {
RpcType::ClientStream | RpcType::BidirectionalStream => true,
RpcType::Unary | RpcType::ServerStream => false,
}
}
}
mod generated {
include!(concat!(env!("OUT_DIR"), "/pw.rpc.packet.rs"));
}
pub use generated::PacketType;
pub use generated::RpcPacket;

View File

@@ -0,0 +1,56 @@
//! Miscellaneous utilities and helpers.
use bytes::{Buf, BufMut};
/// An encoded protobuf message.
///
/// This type represents an encoded protobuf message. Decoding and encoding are
/// essentially no-ops, reading and writing to/from the internal buffer. It is
/// a drop-in replacement for any valid (and invalid) protobuf type.
///
/// This type is intended for reverse-engineering and testing, e.g., in
/// combination with tools like `protoscope`.
#[derive(Clone, Default)]
pub struct EncodedMessage {
pub data: Vec<u8>,
}
impl std::fmt::Debug for EncodedMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:02x?}", self.data)
}
}
impl prost::Message for EncodedMessage {
fn encode_raw(&self, buf: &mut impl BufMut) {
buf.put_slice(&self.data[..])
}
fn merge_field(
&mut self,
_tag: u32,
_wire_type: prost::encoding::WireType,
_buf: &mut impl Buf,
_ctx: prost::encoding::DecodeContext,
) -> Result<(), prost::DecodeError> {
unimplemented!("use merge() instead")
}
fn merge(&mut self, mut buf: impl Buf) -> Result<(), prost::DecodeError> {
let a = self.data.len();
let b = a + buf.remaining();
self.data.resize(b, 0);
buf.copy_to_slice(&mut self.data[a..b]);
Ok(())
}
fn encoded_len(&self) -> usize {
self.data.len()
}
fn clear(&mut self) {
self.data.clear()
}
}

View File

@@ -0,0 +1,36 @@
use crate::protocol::types::{
DosimeterSummary, DosimeterLiveDbMsg,
};
use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse, UnaryRpc};
use crate::pwrpc::Error;
#[derive(Debug, Clone)]
pub struct DosimeterService {
client: ClientHandle,
channel_id: u32,
rpc_fetch_daily_summaries: UnaryRpc<(), DosimeterSummary>,
rpc_sub_live_db: ServerStreamRpc<(), DosimeterLiveDbMsg>,
}
impl DosimeterService {
pub fn new(client: ClientHandle, channel_id: u32) -> Self {
Self {
client,
channel_id,
rpc_fetch_daily_summaries: UnaryRpc::new("maestro_pw.Dosimeter/FetchDailySummaries"),
rpc_sub_live_db: ServerStreamRpc::new("maestro_pw.Dosimeter/SubscribeToLiveDb"),
}
}
pub async fn fetch_daily_summaries(&mut self) -> Result<DosimeterSummary, Error> {
self.rpc_fetch_daily_summaries.call(&mut self.client, self.channel_id, 0, ())?
.result().await
}
pub fn subscribe_to_live_db(&mut self) -> Result<StreamResponse<DosimeterLiveDbMsg>, Error> {
self.rpc_sub_live_db.call(&mut self.client, self.channel_id, 0, ())
}
}

View File

@@ -0,0 +1,117 @@
use crate::protocol::types::{
self, read_setting_msg, settings_rsp, write_setting_msg, HardwareInfo, OobeActionRsp,
ReadSettingMsg, RuntimeInfo, SettingsRsp, SoftwareInfo, WriteSettingMsg,
};
use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse, UnaryRpc};
use crate::pwrpc::Error;
use crate::service::settings::{Setting, SettingId, SettingValue};
#[derive(Debug, Clone)]
pub struct MaestroService {
client: ClientHandle,
channel_id: u32,
rpc_get_software_info: UnaryRpc<(), SoftwareInfo>,
rpc_get_hardware_info: UnaryRpc<(), HardwareInfo>,
rpc_sub_runtime_info: ServerStreamRpc<(), RuntimeInfo>,
rpc_write_setting: UnaryRpc<WriteSettingMsg, ()>,
rpc_read_setting: UnaryRpc<ReadSettingMsg, SettingsRsp>,
rpc_sub_settings_changes: ServerStreamRpc<(), SettingsRsp>,
rpc_sub_oobe_actions: ServerStreamRpc<(), OobeActionRsp>,
}
impl MaestroService {
pub fn new(client: ClientHandle, channel_id: u32) -> Self {
Self {
client,
channel_id,
rpc_get_software_info: UnaryRpc::new("maestro_pw.Maestro/GetSoftwareInfo"),
rpc_get_hardware_info: UnaryRpc::new("maestro_pw.Maestro/GetHardwareInfo"),
rpc_sub_runtime_info: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeRuntimeInfo"),
rpc_write_setting: UnaryRpc::new("maestro_pw.Maestro/WriteSetting"),
rpc_read_setting: UnaryRpc::new("maestro_pw.Maestro/ReadSetting"),
rpc_sub_settings_changes: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeToSettingsChanges"),
rpc_sub_oobe_actions: ServerStreamRpc::new("maestro_pw.Maestro/SubscribeToOobeActions"),
}
}
pub async fn get_software_info(&mut self) -> Result<SoftwareInfo, Error> {
self.rpc_get_software_info.call(&mut self.client, self.channel_id, 0, ())?
.result().await
}
pub async fn get_hardware_info(&mut self) -> Result<HardwareInfo, Error> {
self.rpc_get_hardware_info.call(&mut self.client, self.channel_id, 0, ())?
.result().await
}
pub fn subscribe_to_runtime_info(&mut self) -> Result<StreamResponse<RuntimeInfo>, Error> {
self.rpc_sub_runtime_info.call(&mut self.client, self.channel_id, 0, ())
}
pub async fn write_setting_raw(&mut self, setting: WriteSettingMsg) -> Result<(), Error> {
self.rpc_write_setting.call(&mut self.client, self.channel_id, 0, setting)?
.result().await
}
pub async fn write_setting(&mut self, setting: SettingValue) -> Result<(), Error> {
let setting = types::SettingValue {
value_oneof: Some(setting.into()),
};
let setting = WriteSettingMsg {
value_oneof: Some(write_setting_msg::ValueOneof::Setting(setting)),
};
self.write_setting_raw(setting).await
}
pub async fn read_setting_raw(&mut self, setting: ReadSettingMsg) -> Result<SettingsRsp, Error> {
self.rpc_read_setting.call(&mut self.client, self.channel_id, 0, setting)?
.result().await
}
pub async fn read_setting_var(&mut self, setting: SettingId) -> Result<SettingValue, Error> {
let setting = read_setting_msg::ValueOneof::SettingsId(setting.into());
let setting = ReadSettingMsg { value_oneof: Some(setting) };
let value = self.read_setting_raw(setting).await?;
let value = value.value_oneof
.ok_or_else(|| Error::invalid_argument("did not receive any settings value"))?;
let settings_rsp::ValueOneof::Value(value) = value;
let value = value.value_oneof
.ok_or_else(|| Error::invalid_argument("did not receive any settings value"))?;
Ok(value.into())
}
pub async fn read_setting<T>(&mut self, setting: T) -> Result<T::Type, Error>
where
T: Setting,
{
let value = self.read_setting_var(setting.id()).await?;
T::from_var(value)
.ok_or_else(|| Error::invalid_argument("failed to decode settings value"))
}
pub fn subscribe_to_settings_changes(&mut self) -> Result<StreamResponse<SettingsRsp>, Error> {
self.rpc_sub_settings_changes.call(&mut self.client, self.channel_id, 0, ())
}
pub fn subscribe_to_oobe_actions(&mut self) -> Result<StreamResponse<OobeActionRsp>, Error> {
self.rpc_sub_oobe_actions.call(&mut self.client, self.channel_id, 0, ())
}
// TODO:
// - SetWallClock
}

View File

@@ -0,0 +1,8 @@
mod dosimeter;
pub use self::dosimeter::DosimeterService;
mod maestro;
pub use self::maestro::MaestroService;
mod multipoint;
pub use self::multipoint::MultipointService;

View File

@@ -0,0 +1,30 @@
use crate::protocol::types::QuietModeStatusEvent;
use crate::pwrpc::client::{ClientHandle, ServerStreamRpc, StreamResponse};
use crate::pwrpc::Error;
#[derive(Debug, Clone)]
pub struct MultipointService {
client: ClientHandle,
channel_id: u32,
rpc_sub_quiet_mode_status: ServerStreamRpc<(), QuietModeStatusEvent>,
}
impl MultipointService {
pub fn new(client: ClientHandle, channel_id: u32) -> Self {
Self {
client,
channel_id,
rpc_sub_quiet_mode_status: ServerStreamRpc::new("maestro_pw.Multipoint/SubscribeToQuietModeStatus"),
}
}
pub fn subscribe_to_quiet_mode_status(&mut self) -> Result<StreamResponse<QuietModeStatusEvent>, Error> {
self.rpc_sub_quiet_mode_status.call(&mut self.client, self.channel_id, 0, ())
}
// TODO:
// - ForceMultipointSwitch
}

View File

@@ -0,0 +1,4 @@
pub mod settings;
mod impls;
pub use impls::{MaestroService, MultipointService, DosimeterService};

View File

@@ -0,0 +1,829 @@
use num_enum::{IntoPrimitive, FromPrimitive};
use crate::protocol::types;
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum SettingId {
AutoOtaEnable = 1,
OhdEnable = 2,
OobeIsFinished = 3,
GestureEnable = 4,
DiagnosticsEnable = 5,
OobeMode = 6,
GestureControl = 7,
AncAccessibilityMode = 8,
AncrStateOneBud = 9,
AncrStateTwoBuds = 10,
MultipointEnable = 11,
AncrGestureLoop = 12,
CurrentAncrState = 13,
OttsMode = 14,
VolumeEqEnable = 15,
CurrentUserEq = 16,
VolumeAsymmetry = 17,
LastSavedUserEq = 18,
SumToMono = 19,
VolumeExposureNotifications = 21,
SpeechDetection = 22,
#[num_enum(catch_all)]
Unknown(i32),
}
#[derive(Debug, Clone, PartialEq)]
pub enum SettingValue {
AutoOtaEnable(bool),
OhdEnable(bool),
OobeIsFinished(bool),
GestureEnable(bool),
DiagnosticsEnable(bool),
OobeMode(bool),
GestureControl(GestureControl),
MultipointEnable(bool),
AncrGestureLoop(AncrGestureLoop),
CurrentAncrState(AncState),
OttsMode(i32),
VolumeEqEnable(bool),
CurrentUserEq(EqBands),
VolumeAsymmetry(VolumeAsymmetry),
SumToMono(bool),
VolumeExposureNotifications(bool),
SpeechDetection(bool),
}
impl SettingValue {
pub fn id(&self) -> SettingId {
match self {
SettingValue::AutoOtaEnable(_) => SettingId::AutoOtaEnable,
SettingValue::OhdEnable(_) => SettingId::OhdEnable,
SettingValue::OobeIsFinished(_) => SettingId::OobeIsFinished,
SettingValue::GestureEnable(_) => SettingId::GestureEnable,
SettingValue::DiagnosticsEnable(_) => SettingId::DiagnosticsEnable,
SettingValue::OobeMode(_) => SettingId::OobeMode,
SettingValue::GestureControl(_) => SettingId::GestureControl,
SettingValue::MultipointEnable(_) => SettingId::MultipointEnable,
SettingValue::AncrGestureLoop(_) => SettingId::AncrGestureLoop,
SettingValue::CurrentAncrState(_) => SettingId::CurrentAncrState,
SettingValue::OttsMode(_) => SettingId::OttsMode,
SettingValue::VolumeEqEnable(_) => SettingId::VolumeEqEnable,
SettingValue::CurrentUserEq(_) => SettingId::CurrentUserEq,
SettingValue::VolumeAsymmetry(_) => SettingId::VolumeAsymmetry,
SettingValue::SumToMono(_) => SettingId::SumToMono,
SettingValue::VolumeExposureNotifications(_) => SettingId::VolumeExposureNotifications,
SettingValue::SpeechDetection(_) => SettingId::SpeechDetection,
}
}
}
impl From<types::setting_value::ValueOneof> for SettingValue {
fn from(value: crate::protocol::types::setting_value::ValueOneof) -> Self {
use types::setting_value::ValueOneof;
match value {
ValueOneof::AutoOtaEnable(x) => SettingValue::AutoOtaEnable(x),
ValueOneof::OhdEnable(x) => SettingValue::OhdEnable(x),
ValueOneof::OobeIsFinished(x) => SettingValue::OobeIsFinished(x),
ValueOneof::GestureEnable(x) => SettingValue::GestureEnable(x),
ValueOneof::DiagnosticsEnable(x) => SettingValue::DiagnosticsEnable(x),
ValueOneof::OobeMode(x) => SettingValue::OobeMode(x),
ValueOneof::GestureControl(x) => SettingValue::GestureControl(GestureControl::from(x)),
ValueOneof::MultipointEnable(x) => SettingValue::MultipointEnable(x),
ValueOneof::AncrGestureLoop(x) => SettingValue::AncrGestureLoop(AncrGestureLoop::from(x)),
ValueOneof::CurrentAncrState(x) => SettingValue::CurrentAncrState(AncState::from_primitive(x)),
ValueOneof::OttsMode(x) => SettingValue::OttsMode(x),
ValueOneof::VolumeEqEnable(x) => SettingValue::VolumeEqEnable(x),
ValueOneof::CurrentUserEq(x) => SettingValue::CurrentUserEq(EqBands::from(x)),
ValueOneof::VolumeAsymmetry(x) => SettingValue::VolumeAsymmetry(VolumeAsymmetry::from_raw(x)),
ValueOneof::SumToMono(x) => SettingValue::SumToMono(x),
ValueOneof::VolumeExposureNotifications(x) => SettingValue::VolumeExposureNotifications(x),
ValueOneof::SpeechDetection(x) => SettingValue::SpeechDetection(x),
}
}
}
impl From<SettingValue> for types::setting_value::ValueOneof {
fn from(value: SettingValue) -> Self {
use types::setting_value::ValueOneof;
match value {
SettingValue::AutoOtaEnable(x) => ValueOneof::AutoOtaEnable(x),
SettingValue::OhdEnable(x) => ValueOneof::OhdEnable(x),
SettingValue::OobeIsFinished(x) => ValueOneof::OobeIsFinished(x),
SettingValue::GestureEnable(x) => ValueOneof::GestureEnable(x),
SettingValue::DiagnosticsEnable(x) => ValueOneof::DiagnosticsEnable(x),
SettingValue::OobeMode(x) => ValueOneof::OobeMode(x),
SettingValue::GestureControl(x) => ValueOneof::GestureControl(x.into()),
SettingValue::MultipointEnable(x) => ValueOneof::MultipointEnable(x),
SettingValue::AncrGestureLoop(x) => ValueOneof::AncrGestureLoop(x.into()),
SettingValue::CurrentAncrState(x) => ValueOneof::CurrentAncrState(x.into()),
SettingValue::OttsMode(x) => ValueOneof::OttsMode(x),
SettingValue::VolumeEqEnable(x) => ValueOneof::VolumeEqEnable(x),
SettingValue::CurrentUserEq(x) => ValueOneof::CurrentUserEq(x.into()),
SettingValue::VolumeAsymmetry(x) => ValueOneof::VolumeAsymmetry(x.raw()),
SettingValue::SumToMono(x) => ValueOneof::SumToMono(x),
SettingValue::VolumeExposureNotifications(x) => ValueOneof::VolumeExposureNotifications(x),
SettingValue::SpeechDetection(x) => ValueOneof::SpeechDetection(x),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GestureControl {
pub left: RegularActionTarget,
pub right: RegularActionTarget,
}
impl From<types::GestureControl> for GestureControl {
fn from(value: types::GestureControl) -> Self {
let left = value.left
.and_then(|v| v.value_oneof)
.map(|types::device_gesture_control::ValueOneof::Type(x)| x)
.map(|v| RegularActionTarget::from_primitive(v.value))
.unwrap_or(RegularActionTarget::Unknown(-1));
let right = value.right
.and_then(|v| v.value_oneof)
.map(|types::device_gesture_control::ValueOneof::Type(x)| x)
.map(|v| RegularActionTarget::from_primitive(v.value))
.unwrap_or(RegularActionTarget::Unknown(-1));
GestureControl { left, right }
}
}
impl From<GestureControl> for types::GestureControl {
fn from(value: GestureControl) -> Self {
use types::device_gesture_control::ValueOneof;
let left = types::DeviceGestureControl {
value_oneof: Some(ValueOneof::Type(types::GestureControlType {
value: value.left.into(),
})),
};
let right = types::DeviceGestureControl {
value_oneof: Some(ValueOneof::Type(types::GestureControlType {
value: value.right.into(),
})),
};
Self {
left: Some(left),
right: Some(right),
}
}
}
impl Default for GestureControl {
fn default() -> Self {
Self {
left: RegularActionTarget::AncControl,
right: RegularActionTarget::AncControl,
}
}
}
impl std::fmt::Display for GestureControl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "left: {}, right: {}", self.left, self.right)
}
}
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum RegularActionTarget {
CheckNotifications = 1,
PreviousTrackRepeat = 2,
NextTrack = 3,
PlayPauseTrack = 4,
AncControl = 5,
AssistantQuery = 6,
#[num_enum(catch_all)]
Unknown(i32),
}
impl RegularActionTarget {
pub fn as_str(&self) -> &'static str {
match self {
RegularActionTarget::CheckNotifications => "check-notifications",
RegularActionTarget::PreviousTrackRepeat => "previous",
RegularActionTarget::NextTrack => "next",
RegularActionTarget::PlayPauseTrack => "play-pause",
RegularActionTarget::AncControl => "anc",
RegularActionTarget::AssistantQuery => "assistant",
RegularActionTarget::Unknown(_) => "unknown",
}
}
}
impl std::fmt::Display for RegularActionTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegularActionTarget::CheckNotifications => write!(f, "check-notifications"),
RegularActionTarget::PreviousTrackRepeat => write!(f, "previous"),
RegularActionTarget::NextTrack => write!(f, "next"),
RegularActionTarget::PlayPauseTrack => write!(f, "play-pause"),
RegularActionTarget::AncControl => write!(f, "anc"),
RegularActionTarget::AssistantQuery => write!(f, "assistant"),
RegularActionTarget::Unknown(x) => write!(f, "unknown ({x})"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AncrGestureLoop {
pub active: bool,
pub off: bool,
pub aware: bool,
pub adaptive: bool,
}
impl AncrGestureLoop {
pub fn is_valid(&self) -> bool {
// at least two need to be set
(self.active as u32 + self.off as u32 + self.aware as u32 + self.adaptive as u32) >= 2
}
}
impl From<types::AncrGestureLoop> for AncrGestureLoop {
fn from(other: types::AncrGestureLoop) -> Self {
AncrGestureLoop { active: other.active, off: other.off, aware: other.aware, adaptive: other.adaptive }
}
}
impl From<AncrGestureLoop> for types::AncrGestureLoop {
fn from(other: AncrGestureLoop) -> Self {
Self {
active: other.active,
off: other.off,
aware: other.aware,
adaptive: other.adaptive,
}
}
}
impl std::fmt::Display for AncrGestureLoop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut n = 0;
write!(f, "[")?;
if self.active {
write!(f, "active")?;
n += 1;
}
if self.off {
if n > 0 {
write!(f, ", ")?;
}
write!(f, "off")?;
n += 1;
}
if self.aware {
if n > 0 {
write!(f, ", ")?;
}
write!(f, "aware")?;
}
if self.adaptive {
if n > 0 {
write!(f, ", ")?;
}
write!(f, "adaptive")?;
}
write!(f, "]")
}
}
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPrimitive, FromPrimitive)]
pub enum AncState {
Off = 1,
Active = 2,
Aware = 3,
Adaptive = 4,
#[num_enum(catch_all)]
Unknown(i32),
}
impl AncState {
pub fn as_str(&self) -> &'static str {
match self {
AncState::Off => "off",
AncState::Active => "active",
AncState::Aware => "aware",
AncState::Adaptive => "adaptive",
AncState::Unknown(_) => "unknown",
}
}
}
// #[derive(Default)] clashes with #[derive(FromPrimitive)]
#[allow(clippy::derivable_impls)]
impl Default for AncState {
fn default() -> Self {
AncState::Off
}
}
impl std::fmt::Display for AncState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AncState::Off => write!(f, "off"),
AncState::Active => write!(f, "active"),
AncState::Aware => write!(f, "aware"),
AncState::Adaptive => write!(f, "adaptive"),
AncState::Unknown(x) => write!(f, "unknown ({x})"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EqBands {
low_bass: f32,
bass: f32,
mid: f32,
treble: f32,
upper_treble: f32,
}
impl EqBands {
pub const MIN_VALUE: f32 = -6.0;
pub const MAX_VALUE: f32 = 6.0;
pub fn new(low_bass: f32, bass: f32, mid: f32, treble: f32, upper_treble: f32) -> Self {
Self {
low_bass: low_bass.clamp(Self::MIN_VALUE, Self::MAX_VALUE),
bass: bass.clamp(Self::MIN_VALUE, Self::MAX_VALUE),
mid: mid.clamp(Self::MIN_VALUE, Self::MAX_VALUE),
treble: treble.clamp(Self::MIN_VALUE, Self::MAX_VALUE),
upper_treble: upper_treble.clamp(Self::MIN_VALUE, Self::MAX_VALUE),
}
}
pub fn low_bass(&self) -> f32 {
self.low_bass
}
pub fn bass(&self) -> f32 {
self.bass
}
pub fn mid(&self) -> f32 {
self.mid
}
pub fn treble(&self) -> f32 {
self.treble
}
pub fn upper_treble(&self) -> f32 {
self.upper_treble
}
pub fn set_low_bass(&mut self, value: f32) {
self.low_bass = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE)
}
pub fn set_bass(&mut self, value: f32) {
self.bass = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE)
}
pub fn set_mid(&mut self, value: f32) {
self.mid = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE)
}
pub fn set_treble(&mut self, value: f32) {
self.treble = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE)
}
pub fn set_upper_treble(&mut self, value: f32) {
self.upper_treble = value.clamp(Self::MIN_VALUE, Self::MAX_VALUE)
}
}
impl Default for EqBands {
fn default() -> Self {
Self {
low_bass: 0.0,
bass: 0.0,
mid: 0.0,
treble: 0.0,
upper_treble: 0.0
}
}
}
impl From<types::EqBands> for EqBands {
fn from(other: types::EqBands) -> Self {
Self {
low_bass: other.low_bass,
bass: other.bass,
mid: other.mid,
treble: other.treble,
upper_treble: other.upper_treble,
}
}
}
impl From<EqBands> for types::EqBands {
fn from(other: EqBands) -> Self {
Self {
low_bass: other.low_bass.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE),
bass: other.bass.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE),
mid: other.mid.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE),
treble: other.treble.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE),
upper_treble: other.upper_treble.clamp(EqBands::MIN_VALUE, EqBands::MAX_VALUE),
}
}
}
impl std::fmt::Display for EqBands {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f, "[{:.2}, {:.2}, {:.2}, {:.2}, {:.2}]",
self.low_bass, self.bass, self.mid, self.treble, self.upper_treble,
)
}
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub struct VolumeAsymmetry {
value: i32,
}
impl VolumeAsymmetry {
pub fn from_normalized(value: i32) -> Self {
Self { value: value.clamp(-100, 100) }
}
pub fn from_raw(value: i32) -> Self {
let direction = value & 0x01;
let value = value >> 1;
let normalized = if direction != 0 {
value + 1
} else {
- value
};
Self { value: normalized }
}
pub fn raw(&self) -> i32 {
if self.value > 0 {
((self.value - 1) << 1) | 0x01
} else {
(-self.value) << 1
}
}
pub fn value(&self) -> i32 {
self.value
}
}
impl std::fmt::Debug for VolumeAsymmetry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl std::fmt::Display for VolumeAsymmetry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let left = (100 - self.value).min(100);
let right = (100 + self.value).min(100);
write!(f, "left: {left}%, right: {right}%")
}
}
pub trait Setting {
type Type;
fn id(&self) -> SettingId;
fn from_var(var: SettingValue) -> Option<Self::Type>;
}
impl Setting for SettingId {
type Type = SettingValue;
fn id(&self) -> SettingId {
*self
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
Some(var)
}
}
pub mod id {
use super::*;
pub struct AutoOtaEnable;
pub struct OhdEnable;
pub struct OobeIsFinished;
pub struct GestureEnable;
pub struct DiagnosticsEnable;
pub struct OobeMode;
pub struct GestureControl;
pub struct MultipointEnable;
pub struct AncrGestureLoop;
pub struct CurrentAncrState;
pub struct OttsMode;
pub struct VolumeEqEnable;
pub struct CurrentUserEq;
pub struct VolumeAsymmetry;
pub struct SumToMono;
pub struct VolumeExposureNotifications;
pub struct SpeechDetection;
impl Setting for AutoOtaEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::AutoOtaEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::AutoOtaEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for OhdEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::OhdEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::OhdEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for OobeIsFinished {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::OobeIsFinished
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::OobeIsFinished(x) => Some(x),
_ => None,
}
}
}
impl Setting for GestureEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::GestureEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::GestureEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for DiagnosticsEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::DiagnosticsEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::DiagnosticsEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for OobeMode {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::OobeMode
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::OobeMode(x) => Some(x),
_ => None,
}
}
}
impl Setting for GestureControl {
type Type = super::GestureControl;
fn id(&self) -> SettingId {
SettingId::GestureControl
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::GestureControl(x) => Some(x),
_ => None,
}
}
}
impl Setting for MultipointEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::MultipointEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::MultipointEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for AncrGestureLoop {
type Type = super::AncrGestureLoop;
fn id(&self) -> SettingId {
SettingId::AncrGestureLoop
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::AncrGestureLoop(x) => Some(x),
_ => None,
}
}
}
impl Setting for CurrentAncrState {
type Type = AncState;
fn id(&self) -> SettingId {
SettingId::CurrentAncrState
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::CurrentAncrState(x) => Some(x),
_ => None,
}
}
}
impl Setting for OttsMode {
type Type = i32;
fn id(&self) -> SettingId {
SettingId::OttsMode
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::OttsMode(x) => Some(x),
_ => None,
}
}
}
impl Setting for VolumeEqEnable {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::VolumeEqEnable
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::VolumeEqEnable(x) => Some(x),
_ => None,
}
}
}
impl Setting for CurrentUserEq {
type Type = EqBands;
fn id(&self) -> SettingId {
SettingId::CurrentUserEq
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::CurrentUserEq(x) => Some(x),
_ => None,
}
}
}
impl Setting for VolumeAsymmetry {
type Type = super::VolumeAsymmetry;
fn id(&self) -> SettingId {
SettingId::VolumeAsymmetry
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::VolumeAsymmetry(x) => Some(x),
_ => None,
}
}
}
impl Setting for SumToMono {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::SumToMono
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::SumToMono(x) => Some(x),
_ => None,
}
}
}
impl Setting for VolumeExposureNotifications {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::VolumeExposureNotifications
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::VolumeExposureNotifications(x) => Some(x),
_ => None,
}
}
}
impl Setting for SpeechDetection {
type Type = bool;
fn id(&self) -> SettingId {
SettingId::SpeechDetection
}
fn from_var(var: SettingValue) -> Option<Self::Type> {
match var {
SettingValue::SpeechDetection(x) => Some(x),
_ => None,
}
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_volume_assymetry_conversion() {
for i in 0..=200 {
assert_eq!(VolumeAsymmetry::from_raw(i).raw(), i)
}
}
}

20
tui/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "pbpctui"
authors = ["Cikki <jasoncheng@hifuu.ink>"]
version = "0.1.5"
edition = "2024"
license = "MIT/Apache-2.0"
description = "TUI utility for pbpctrl"
[dependencies]
ratatui = "0.30.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
tokio = { version = "1.44.2", features = ["full"] }
anyhow = "1.0.98"
regex = "1.10.4"
maestro = { path = "../libmaestro" }
bluer = { version = "0.17.4", features = ["bluetoothd", "rfcomm"] }
futures = "0.3.31"
tokio-util = { version = "0.7.14", features = ["codec"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"

237
tui/src/app.rs Normal file
View File

@@ -0,0 +1,237 @@
use ratatui::widgets::ListState;
use crate::maestro_client::{ConnectionState, BatteryState, SoftwareInfo, HardwareInfo, RuntimeInfo};
#[derive(Debug, Clone)]
pub struct SettingItem {
pub key: String, // Command key for CLI, e.g., "anc"
pub name: String, // Display name
pub value: String, // Current value string
pub options: Vec<String>, // Possible values to cycle through. Empty means read-only.
pub index: Option<usize>, // For array-like settings (e.g., EQ bands)
pub range: Option<(f32, f32, f32)>, // (min, max, step) for numeric adjustments
}
pub struct App {
pub should_quit: bool,
pub connection_state: ConnectionState,
pub battery: BatteryState,
pub software: SoftwareInfo,
pub hardware: HardwareInfo,
pub runtime: RuntimeInfo,
pub gesture_control: String, // Hold gestures info
pub selected_tab: usize,
pub tabs: Vec<String>,
pub settings_state: ListState,
pub settings: Vec<SettingItem>,
pub last_error: Option<String>,
pub last_error_time: Option<std::time::Instant>,
}
impl App {
pub fn new() -> Self {
let mut settings_state = ListState::default();
settings_state.select(Some(0));
Self {
should_quit: false,
connection_state: ConnectionState::Disconnected,
battery: BatteryState::default(),
software: SoftwareInfo::default(),
hardware: HardwareInfo::default(),
runtime: RuntimeInfo::default(),
gesture_control: "Unknown".to_string(),
selected_tab: 0,
tabs: vec!["Status".to_string(), "Settings".to_string()],
settings_state,
settings: vec![
// --- Audio & Noise Control ---
SettingItem {
key: "anc".to_string(),
name: "ANC Mode".to_string(),
value: "Pending...".to_string(),
options: vec!["off".to_string(), "active".to_string(), "adaptive".to_string(), "aware".to_string()],
index: None, range: None,
},
SettingItem {
key: "volume-eq".to_string(),
name: "Volume EQ".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "mono".to_string(),
name: "Mono Audio".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "speech-detection".to_string(),
name: "Conversation Detect".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
// --- Connectivity & Sensors ---
SettingItem {
key: "multipoint".to_string(),
name: "Multipoint".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "ohd".to_string(),
name: "In-Ear Detection".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
// --- Controls ---
SettingItem {
key: "gestures".to_string(),
name: "Touch Controls".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
// --- System & Diagnostics ---
SettingItem {
key: "volume-exposure-notifications".to_string(),
name: "Volume Notifications".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "diagnostics".to_string(),
name: "Diagnostics".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "oobe-mode".to_string(),
name: "OOBE Mode".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
SettingItem {
key: "oobe-is-finished".to_string(),
name: "OOBE Finished".to_string(),
value: "Pending...".to_string(),
options: vec!["true".to_string(), "false".to_string()],
index: None, range: None,
},
// --- Numeric / Range Settings ---
SettingItem {
key: "balance".to_string(),
name: "Volume Balance".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: None,
range: Some((-100.0, 100.0, 5.0)), // Min, Max, Step
},
SettingItem {
key: "eq".to_string(),
name: "EQ: Low Bass".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: Some(0),
range: Some((-6.0, 6.0, 0.5)),
},
SettingItem {
key: "eq".to_string(),
name: "EQ: Bass".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: Some(1),
range: Some((-6.0, 6.0, 0.5)),
},
SettingItem {
key: "eq".to_string(),
name: "EQ: Mid".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: Some(2),
range: Some((-6.0, 6.0, 0.5)),
},
SettingItem {
key: "eq".to_string(),
name: "EQ: Treble".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: Some(3),
range: Some((-6.0, 6.0, 0.5)),
},
SettingItem {
key: "eq".to_string(),
name: "EQ: Upper Treble".to_string(),
value: "Pending...".to_string(),
options: vec![],
index: Some(4),
range: Some((-6.0, 6.0, 0.5)),
},
],
last_error: None,
last_error_time: None,
}
}
pub fn set_error(&mut self, msg: String) {
self.last_error = Some(msg);
self.last_error_time = Some(std::time::Instant::now());
}
pub fn on_tick(&mut self) {
// Clear error after 5 seconds
if self.last_error_time.is_some_and(|time| time.elapsed() > std::time::Duration::from_secs(5)) {
self.last_error = None;
self.last_error_time = None;
}
}
pub fn next_tab(&mut self) {
self.selected_tab = (self.selected_tab + 1) % self.tabs.len();
}
#[allow(dead_code)]
pub fn previous_tab(&mut self) {
if self.selected_tab > 0 {
self.selected_tab -= 1;
} else {
self.selected_tab = self.tabs.len() - 1;
}
}
pub fn next_setting(&mut self) {
if let Some(selected) = self.settings_state.selected() {
let next = (selected + 1) % self.settings.len();
self.settings_state.select(Some(next));
}
}
pub fn previous_setting(&mut self) {
if let Some(selected) = self.settings_state.selected() {
let next = if selected == 0 { self.settings.len() - 1 } else { selected - 1 };
self.settings_state.select(Some(next));
}
}
pub fn update_setting(&mut self, key: String, val: String) {
for item in &mut self.settings {
if item.key == key {
// Normalize value (lowercase)
item.value = val.to_lowercase();
}
}
}
}

100
tui/src/bt.rs Normal file
View File

@@ -0,0 +1,100 @@
use std::time::Duration;
use anyhow::Result;
use bluer::{Adapter, Address, Device, Session};
use bluer::rfcomm::{ProfileHandle, Role, ReqError, Stream, Profile};
use futures::StreamExt;
const PIXEL_BUDS_CLASS: u32 = 0x240404;
const PIXEL_BUDS2_CLASS: u32 = 0x244404;
/// Timeout for the entire RFCOMM connection process
const RFCOMM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
pub async fn find_maestro_device(adapter: &Adapter) -> Result<Device> {
for addr in adapter.device_addresses().await? {
let dev = adapter.device(addr)?;
let class = dev.class().await?.unwrap_or(0);
if class != PIXEL_BUDS_CLASS && class != PIXEL_BUDS2_CLASS {
continue;
}
let uuids = dev.uuids().await?.unwrap_or_default();
if !uuids.contains(&maestro::UUID) {
continue;
}
tracing::debug!(address=%addr, "found compatible device");
return Ok(dev);
}
tracing::debug!("no compatible device found");
anyhow::bail!("no compatible device found")
}
pub async fn connect_maestro_rfcomm(session: &Session, dev: &Device) -> Result<Stream> {
let maestro_profile = Profile {
uuid: maestro::UUID,
role: Some(Role::Client),
require_authentication: Some(false),
require_authorization: Some(false),
auto_connect: Some(false),
..Default::default()
};
tracing::debug!("registering maestro profile");
let mut handle = session.register_profile(maestro_profile).await?;
tracing::debug!("connecting to maestro profile");
// Add timeout to prevent hanging when device disconnects during connection
let connect_future = async {
tokio::try_join!(
try_connect_profile(dev),
handle_requests_for_profile(&mut handle, dev.address()),
)
};
let stream = tokio::time::timeout(RFCOMM_CONNECT_TIMEOUT, connect_future)
.await
.map_err(|_| anyhow::anyhow!("RFCOMM connection timed out"))??.1;
Ok(stream)
}
async fn try_connect_profile(dev: &Device) -> Result<()> {
const RETRY_TIMEOUT: Duration = Duration::from_secs(1);
const MAX_TRIES: u32 = 3;
let mut i = 0;
while let Err(err) = dev.connect_profile(&maestro::UUID).await {
if i >= MAX_TRIES { return Err(err.into()) }
i += 1;
tracing::debug!(error=?err, "connecting to profile failed, trying again ({}/{})", i, MAX_TRIES);
tokio::time::sleep(RETRY_TIMEOUT).await;
}
tracing::debug!(address=%dev.address(), "maestro profile connected");
Ok(())
}
async fn handle_requests_for_profile(handle: &mut ProfileHandle, address: Address) -> Result<Stream> {
while let Some(req) = handle.next().await {
tracing::debug!(address=%req.device(), "received new profile connection request");
if req.device() == address {
tracing::debug!(address=%req.device(), "accepting profile connection request");
return Ok(req.accept()?);
} else {
req.reject(ReqError::Rejected);
}
}
anyhow::bail!("profile terminated without requests")
}

418
tui/src/maestro_client.rs Normal file
View File

@@ -0,0 +1,418 @@
use std::time::Duration;
use tokio::sync::mpsc;
use anyhow::Result;
use futures::StreamExt;
use maestro::protocol::codec::Codec;
use maestro::protocol::utils;
use maestro::protocol::addr;
use maestro::pwrpc::client::Client;
use maestro::service::MaestroService;
use maestro::service::settings::{self, SettingValue, Setting};
use maestro::protocol::types::RuntimeInfo as MRuntimeInfo;
use crate::bt;
/// Timeout for individual service commands
const COMMAND_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, PartialEq)]
pub enum ConnectionState {
Disconnected,
Connected,
}
#[derive(Debug, Clone, Default)]
pub struct BatteryState {
pub case_level: Option<u8>,
pub case_status: String,
pub left_level: Option<u8>,
pub left_status: String,
pub right_level: Option<u8>,
pub right_status: String,
}
#[derive(Debug, Clone, Default)]
pub struct SoftwareInfo {
pub case_version: String,
pub left_version: String,
pub right_version: String,
}
#[derive(Debug, Clone, Default)]
pub struct HardwareInfo {
pub case_serial: String,
pub left_serial: String,
pub right_serial: String,
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeInfo {
pub battery: BatteryState,
pub placement_left: String,
pub placement_right: String,
pub peer_local: String,
pub peer_remote: String,
}
#[derive(Debug, Clone)]
pub enum ClientEvent {
ConnectionState(ConnectionState),
Software(SoftwareInfo),
Hardware(HardwareInfo),
Runtime(RuntimeInfo),
Setting(String, String), // key, value
Error(String),
}
#[derive(Debug, Clone)]
pub enum ClientCommand {
CheckConnection,
GetSoftware,
GetHardware,
GetSetting(String),
SetSetting(String, String),
}
pub async fn run_loop(
tx: mpsc::UnboundedSender<ClientEvent>,
mut rx: mpsc::UnboundedReceiver<ClientCommand>,
) {
let session = match bluer::Session::new().await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(ClientEvent::Error(format!("Bluetooth session error: {}", e)));
return;
}
};
let adapter = match session.default_adapter().await {
Ok(a) => a,
Err(e) => {
let _ = tx.send(ClientEvent::Error(format!("Bluetooth adapter error: {}", e)));
return;
}
};
let _ = adapter.set_powered(true).await;
loop {
// 1. Establish connection
let dev = match bt::find_maestro_device(&adapter).await {
Ok(d) => d,
Err(_) => {
tokio::time::sleep(Duration::from_secs(2)).await;
if let Ok(_cmd) = rx.try_recv() {
// process minimal commands?
}
continue;
}
};
let stream = match bt::connect_maestro_rfcomm(&session, &dev).await {
Ok(s) => s,
Err(e) => {
// Only send error if it's not a transient connection failure
let err_str = e.to_string();
if !err_str.contains("not available") && !err_str.contains("not connected") {
let _ = tx.send(ClientEvent::Error(format!("Connection failed: {}", e)));
}
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
let codec = Codec::new();
let stream = codec.wrap(stream);
let mut client = Client::new(stream);
let handle = client.handle();
let channel_res = tokio::time::timeout(
Duration::from_secs(10),
utils::resolve_channel(&mut client)
).await;
let channel = match channel_res {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
let _ = tx.send(ClientEvent::Error(format!("Channel resolution failed: {}", e)));
continue;
}
Err(_) => {
let _ = tx.send(ClientEvent::Error("Channel resolution timed out".to_string()));
continue;
}
};
let mut service = MaestroService::new(handle.clone(), channel);
let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Connected));
// Subscribe to changes.
let mut settings_sub = match service.subscribe_to_settings_changes() {
Ok(call) => Some(call),
Err(e) => {
let _ = tx.send(ClientEvent::Error(format!("Settings sub failed: {}", e)));
None
}
};
let mut runtime_sub = match service.subscribe_to_runtime_info() {
Ok(call) => Some(call),
Err(e) => {
let _ = tx.send(ClientEvent::Error(format!("Runtime sub failed: {}", e)));
None
}
};
// Spawn client run loop to ensure packet processing happens concurrently with command handling
let mut client_task = tokio::spawn(async move { client.run().await });
// Inner loop: Connected state
loop {
tokio::select! {
res = &mut client_task => {
// Client task finished (error or disconnect)
// Clear subscriptions immediately to prevent blocking
settings_sub = None;
runtime_sub = None;
let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Disconnected));
match res {
Ok(Err(e)) => {
// Only send error if it's not a connection reset (expected on disconnect)
let err_str = e.to_string();
if !err_str.contains("connection reset") && !err_str.contains("os error 104") {
let _ = tx.send(ClientEvent::Error(format!("Client error: {}", e)));
}
}
Ok(Ok(_)) => {} // Clean exit?
Err(e) => { let _ = tx.send(ClientEvent::Error(format!("Client task join error: {}", e))); }
}
break;
}
cmd = rx.recv() => {
match cmd {
Some(c) => handle_command(c, &mut service, &tx).await,
None => return,
}
}
Some(res) = async { settings_sub.as_mut()?.stream().next().await }, if settings_sub.is_some() => {
match res {
Ok(rsp) => {
if let Some(val) = rsp.value_oneof {
use maestro::protocol::types::settings_rsp;
let settings_rsp::ValueOneof::Value(sv) = val;
// sv is types::SettingValue
if let Some(vo) = sv.value_oneof {
let setting: SettingValue = vo.into();
process_setting_change(setting, &tx);
}
}
}
Err(_) => {
// Settings subscription error, clear it to prevent blocking
settings_sub = None;
}
}
}
Some(res) = async { runtime_sub.as_mut()?.stream().next().await }, if runtime_sub.is_some() => {
match res {
Ok(info) => {
let r_info = convert_runtime_info(info, channel);
let _ = tx.send(ClientEvent::Runtime(r_info));
}
Err(_) => {
// Runtime subscription error, clear it to prevent blocking
runtime_sub = None;
}
}
}
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
async fn handle_command(cmd: ClientCommand, service: &mut MaestroService, tx: &mpsc::UnboundedSender<ClientEvent>) {
match cmd {
ClientCommand::CheckConnection => {
let _ = tx.send(ClientEvent::ConnectionState(ConnectionState::Connected));
}
ClientCommand::GetSoftware => {
let result = tokio::time::timeout(
COMMAND_TIMEOUT,
service.get_software_info()
).await;
match result {
Ok(Ok(info)) => {
let sw = SoftwareInfo {
case_version: info.firmware.as_ref().and_then(|f| f.case.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(),
left_version: info.firmware.as_ref().and_then(|f| f.left.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(),
right_version: info.firmware.as_ref().and_then(|f| f.right.as_ref()).map(|v| v.version_string.clone()).unwrap_or_default(),
};
let _ = tx.send(ClientEvent::Software(sw));
}
Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("GetSoftware failed: {}", e))); }
Err(_) => { let _ = tx.send(ClientEvent::Error("GetSoftware timed out".to_string())); }
}
}
ClientCommand::GetHardware => {
let result = tokio::time::timeout(
COMMAND_TIMEOUT,
service.get_hardware_info()
).await;
match result {
Ok(Ok(info)) => {
let hw = HardwareInfo {
case_serial: info.serial_number.as_ref().map(|s| s.case.clone()).unwrap_or_default(),
left_serial: info.serial_number.as_ref().map(|s| s.left.clone()).unwrap_or_default(),
right_serial: info.serial_number.as_ref().map(|s| s.right.clone()).unwrap_or_default(),
};
let _ = tx.send(ClientEvent::Hardware(hw));
}
Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("GetHardware failed: {}", e))); }
Err(_) => { let _ = tx.send(ClientEvent::Error("GetHardware timed out".to_string())); }
}
}
ClientCommand::GetSetting(key) => {
let res = match key.as_str() {
"anc" => read_and_send(service, settings::id::CurrentAncrState, &key, tx).await,
"volume-eq" => read_and_send(service, settings::id::VolumeEqEnable, &key, tx).await,
"mono" => read_and_send(service, settings::id::SumToMono, &key, tx).await,
"speech-detection" => read_and_send(service, settings::id::SpeechDetection, &key, tx).await,
"multipoint" => read_and_send(service, settings::id::MultipointEnable, &key, tx).await,
"ohd" => read_and_send(service, settings::id::OhdEnable, &key, tx).await,
"gestures" => read_and_send(service, settings::id::GestureEnable, &key, tx).await,
"volume-exposure-notifications" => read_and_send(service, settings::id::VolumeExposureNotifications, &key, tx).await,
"diagnostics" => read_and_send(service, settings::id::DiagnosticsEnable, &key, tx).await,
"oobe-mode" => read_and_send(service, settings::id::OobeMode, &key, tx).await,
"oobe-is-finished" => read_and_send(service, settings::id::OobeIsFinished, &key, tx).await,
"balance" => read_and_send(service, settings::id::VolumeAsymmetry, &key, tx).await,
"eq" => read_and_send(service, settings::id::CurrentUserEq, &key, tx).await,
"gesture-control" => read_and_send(service, settings::id::GestureControl, &key, tx).await,
_ => Ok(()),
};
if let Err(e) = res {
let _ = tx.send(ClientEvent::Error(format!("Get {} failed: {}", key, e)));
}
}
ClientCommand::SetSetting(key, val) => {
let write_result = async {
match key.as_str() {
"anc" => {
let state = match val.as_str() {
"active" => settings::AncState::Active,
"aware" => settings::AncState::Aware,
"off" => settings::AncState::Off,
"adaptive" => settings::AncState::Adaptive,
_ => settings::AncState::Off,
};
service.write_setting(SettingValue::CurrentAncrState(state)).await
},
"volume-eq" => service.write_setting(SettingValue::VolumeEqEnable(val == "true")).await,
"mono" => service.write_setting(SettingValue::SumToMono(val == "true")).await,
"speech-detection" => service.write_setting(SettingValue::SpeechDetection(val == "true")).await,
"multipoint" => service.write_setting(SettingValue::MultipointEnable(val == "true")).await,
"ohd" => service.write_setting(SettingValue::OhdEnable(val == "true")).await,
"gestures" => service.write_setting(SettingValue::GestureEnable(val == "true")).await,
"volume-exposure-notifications" => service.write_setting(SettingValue::VolumeExposureNotifications(val == "true")).await,
"diagnostics" => service.write_setting(SettingValue::DiagnosticsEnable(val == "true")).await,
"oobe-mode" => service.write_setting(SettingValue::OobeMode(val == "true")).await,
"oobe-is-finished" => service.write_setting(SettingValue::OobeIsFinished(val == "true")).await,
"balance" => {
if let Ok(n) = val.parse::<i32>() {
let va = settings::VolumeAsymmetry::from_normalized(n);
service.write_setting(SettingValue::VolumeAsymmetry(va)).await
} else {
Ok(())
}
},
"eq" => {
let parts: Vec<f32> = val.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if parts.len() == 5 {
let eq = settings::EqBands::new(parts[0], parts[1], parts[2], parts[3], parts[4]);
service.write_setting(SettingValue::CurrentUserEq(eq)).await
} else {
Ok(())
}
},
_ => Ok(()),
}
};
match tokio::time::timeout(COMMAND_TIMEOUT, write_result).await {
Ok(Ok(())) => {}
Ok(Err(e)) => { let _ = tx.send(ClientEvent::Error(format!("Set {} failed: {}", key, e))); }
Err(_) => { let _ = tx.send(ClientEvent::Error(format!("Set {} timed out", key))); }
}
}
}
}
async fn read_and_send<T>(service: &mut MaestroService, setting: T, key: &str, tx: &mpsc::UnboundedSender<ClientEvent>) -> Result<(), maestro::pwrpc::Error>
where T: Setting, T::Type: std::fmt::Display {
let result = tokio::time::timeout(
COMMAND_TIMEOUT,
service.read_setting(setting)
).await;
match result {
Ok(Ok(val)) => {
let _ = tx.send(ClientEvent::Setting(key.to_string(), val.to_string()));
Ok(())
}
Ok(Err(e)) => Err(e),
Err(_) => {
let _ = tx.send(ClientEvent::Error(format!("Get {} timed out", key)));
Ok(())
}
}
}
fn process_setting_change(setting: SettingValue, tx: &mpsc::UnboundedSender<ClientEvent>) {
let (key, val) = match setting {
SettingValue::CurrentAncrState(s) => ("anc", s.to_string()),
SettingValue::VolumeEqEnable(b) => ("volume-eq", b.to_string()),
SettingValue::SumToMono(b) => ("mono", b.to_string()),
SettingValue::SpeechDetection(b) => ("speech-detection", b.to_string()),
SettingValue::MultipointEnable(b) => ("multipoint", b.to_string()),
SettingValue::OhdEnable(b) => ("ohd", b.to_string()),
SettingValue::GestureEnable(b) => ("gestures", b.to_string()),
SettingValue::VolumeExposureNotifications(b) => ("volume-exposure-notifications", b.to_string()),
SettingValue::DiagnosticsEnable(b) => ("diagnostics", b.to_string()),
SettingValue::OobeMode(b) => ("oobe-mode", b.to_string()),
SettingValue::OobeIsFinished(b) => ("oobe-is-finished", b.to_string()),
SettingValue::VolumeAsymmetry(va) => ("balance", va.to_string()),
SettingValue::CurrentUserEq(eq) => ("eq", eq.to_string()),
SettingValue::GestureControl(gc) => ("gesture-control", format!("{:?}", gc)),
_ => return,
};
let _ = tx.send(ClientEvent::Setting(key.to_string(), val.to_lowercase()));
}
fn convert_runtime_info(info: MRuntimeInfo, channel: u32) -> RuntimeInfo {
let address = addr::address_for_channel(channel);
let peer_local = address.map(|a| format!("{:?}", a.source())).unwrap_or_else(|| "unknown".to_string());
let peer_remote = address.map(|a| format!("{:?}", a.target())).unwrap_or_else(|| "unknown".to_string());
RuntimeInfo {
battery: BatteryState {
case_level: info.battery_info.as_ref().and_then(|b| b.case.as_ref()).map(|b| b.level as u8),
case_status: info.battery_info.as_ref().and_then(|b| b.case.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(),
left_level: info.battery_info.as_ref().and_then(|b| b.left.as_ref()).map(|b| b.level as u8),
left_status: info.battery_info.as_ref().and_then(|b| b.left.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(),
right_level: info.battery_info.as_ref().and_then(|b| b.right.as_ref()).map(|b| b.level as u8),
right_status: info.battery_info.as_ref().and_then(|b| b.right.as_ref()).map(|b| if b.state == 2 { "charging" } else { "not charging" }).unwrap_or("unknown").to_string(),
},
placement_left: info.placement.as_ref().map(|p| if p.left_bud_in_case { "in case" } else { "out of case" }).unwrap_or("unknown").to_string(),
placement_right: info.placement.as_ref().map(|p| if p.right_bud_in_case { "in case" } else { "out of case" }).unwrap_or("unknown").to_string(),
peer_local,
peer_remote,
}
}

280
tui/src/main.rs Normal file
View File

@@ -0,0 +1,280 @@
use std::{io, time::Duration};
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::mpsc;
mod app;
mod bt;
mod maestro_client;
mod ui;
use app::App;
use maestro_client::{ClientCommand, ClientEvent};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_writer(std::io::stderr)
.init();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = App::new();
// Create channels
let (tx_event, rx_event) = mpsc::unbounded_channel();
let (tx_cmd, rx_cmd) = mpsc::unbounded_channel();
// Spawn client in a separate blocking task to isolate it completely
tokio::spawn(maestro_client::run_loop(tx_event, rx_cmd));
// Initial check
tx_cmd.send(ClientCommand::CheckConnection).ok();
let res = run_app(&mut terminal, &mut app, tx_cmd, rx_event).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
tx_cmd: mpsc::UnboundedSender<ClientCommand>,
mut rx_event: mpsc::UnboundedReceiver<ClientEvent>,
) -> Result<()>
where
<B as ratatui::backend::Backend>::Error: std::marker::Send + Sync + 'static,
{
let tick_rate = Duration::from_millis(50);
loop {
terminal.draw(|f| ui::draw(f, app))?;
// Poll for keyboard events with timeout - this is non-blocking
if event::poll(tick_rate)? && let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Tab => {
app.next_tab();
}
KeyCode::Char('c') => {
tx_cmd.send(ClientCommand::CheckConnection).ok();
}
KeyCode::Down | KeyCode::Char('j') => {
if app.selected_tab == 1 {
app.next_setting();
}
}
KeyCode::Up | KeyCode::Char('k') => {
if app.selected_tab == 1 {
app.previous_setting();
}
}
KeyCode::Left => {
if app.selected_tab == 1 {
handle_numeric_change(app, &tx_cmd, -1.0);
}
}
KeyCode::Right => {
if app.selected_tab == 1 {
handle_numeric_change(app, &tx_cmd, 1.0);
}
}
KeyCode::Enter => {
if app.selected_tab == 1 {
handle_setting_change(app, &tx_cmd);
}
}
_ => {}
}
}
// Process all pending client events (non-blocking)
while let Ok(event) = rx_event.try_recv() {
process_client_event(app, &tx_cmd, event)?;
}
app.on_tick();
if app.should_quit {
return Ok(());
}
}
}
fn process_client_event(
app: &mut App,
tx_cmd: &mpsc::UnboundedSender<ClientCommand>,
event: ClientEvent,
) -> Result<()> {
match event {
ClientEvent::ConnectionState(state) => {
app.connection_state = state.clone();
if matches!(state, maestro_client::ConnectionState::Connected) {
tx_cmd.send(ClientCommand::GetSoftware)?;
tx_cmd.send(ClientCommand::GetHardware)?;
let mut fetched_keys = std::collections::HashSet::new();
for item in &app.settings {
if !fetched_keys.contains(&item.key) {
tx_cmd.send(ClientCommand::GetSetting(item.key.clone()))?;
fetched_keys.insert(item.key.clone());
}
}
tx_cmd.send(ClientCommand::GetSetting("gesture-control".to_string()))?;
}
}
ClientEvent::Software(info) => {
app.software = info;
}
ClientEvent::Hardware(info) => {
app.hardware = info;
}
ClientEvent::Runtime(info) => {
app.runtime = info.clone();
app.battery = info.battery;
}
ClientEvent::Setting(key, val) => {
if key == "gesture-control" {
app.gesture_control = val;
} else if key == "eq" {
let trimmed = val.trim_matches(|c| c == '[' || c == ']');
let parts: Vec<&str> = trimmed.split(',').map(|s| s.trim()).collect();
if parts.len() == 5 {
for (i, part) in parts.iter().enumerate() {
if part.parse::<f32>().is_ok()
&& let Some(item) = app.settings.iter_mut()
.find(|it| it.key == "eq" && it.index == Some(i))
{
item.value = part.to_string();
}
}
}
} else {
app.update_setting(key, val);
}
}
ClientEvent::Error(msg) => {
app.set_error(msg);
}
}
Ok(())
}
fn handle_numeric_change(app: &mut App, tx_cmd: &mpsc::UnboundedSender<ClientCommand>, direction: f32) {
if let Some(idx) = app.settings_state.selected() {
let item = &mut app.settings[idx];
if let Some((min, max, step)) = item.range {
let change = direction * step;
if item.key == "balance" {
// Parse current balance
let current_val = if item.value.contains("left:") {
let parts: Vec<&str> = item.value.split(',').collect();
let mut l = 100;
let mut r = 100;
for part in parts {
if let Some(v) = part.split(':').nth(1) {
let n = v.trim().trim_end_matches('%').parse::<i32>().unwrap_or(100);
if part.contains("left") { l = n; }
if part.contains("right") { r = n; }
}
}
if r == 100 {
100 - l
} else {
r - 100
}
} else {
0
};
let new_val = (current_val as f32 + change).clamp(min, max) as i32;
let l = (100 - new_val).min(100);
let r = (100 + new_val).min(100);
item.value = format!("left: {}%, right: {}%", l, r);
let _ = tx_cmd.send(ClientCommand::SetSetting(item.key.clone(), new_val.to_string()));
} else if item.key == "eq" {
let current_val = item.value.parse::<f32>().unwrap_or(0.0);
let new_val = (current_val + change).clamp(min, max);
item.value = format!("{:.2}", new_val);
}
}
}
// Split logic to avoid borrow checker issues for EQ
if let Some(idx) = app.settings_state.selected() {
let key = app.settings[idx].key.clone();
if key == "eq" {
let mut eq_values = [0.0f32; 5];
for it in &app.settings {
if it.key == "eq"
&& let Some(i) = it.index
&& i < 5 {
eq_values[i] = it.value.parse::<f32>().unwrap_or(0.0);
}
}
let args = eq_values.iter().map(|v| format!("{:.2}", v)).collect::<Vec<_>>().join(" ");
let _ = tx_cmd.send(ClientCommand::SetSetting("eq".to_string(), args));
}
}
}
fn handle_setting_change(app: &App, tx_cmd: &mpsc::UnboundedSender<ClientCommand>) {
if let Some(idx) = app.settings_state.selected() {
let item = &app.settings[idx];
if item.options.is_empty() {
return;
}
let current_val = item.value.to_lowercase();
let current_opt_idx = item.options.iter().position(|o| o.to_lowercase() == current_val);
let next_val = if let Some(i) = current_opt_idx {
item.options[(i + 1) % item.options.len()].clone()
} else if !item.options.is_empty() {
item.options[0].clone()
} else {
return;
};
let _ = tx_cmd.send(ClientCommand::SetSetting(item.key.clone(), next_val));
}
}

239
tui/src/ui.rs Normal file
View File

@@ -0,0 +1,239 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect, Alignment},
style::{Color, Style, Modifier},
text::{Line, Span},
widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Tabs, Row, Table},
Frame,
};
use crate::app::App;
use crate::maestro_client::ConnectionState;
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tabs
Constraint::Min(0), // Content
Constraint::Length(1), // Status/Help
])
.split(f.area());
draw_tabs(f, app, chunks[0]);
match app.selected_tab {
0 => draw_status(f, app, chunks[1]),
1 => draw_settings(f, app, chunks[1]),
_ => {},
}
draw_help(f, app, chunks[2]);
}
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
let titles: Vec<Line> = app
.tabs
.iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::Green))))
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title("Pixel Buds Pro Control"))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.select(app.selected_tab);
f.render_widget(tabs, area);
}
fn draw_status(f: &mut Frame, app: &App, area: Rect) {
let main_layout = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3), // Connection Header
Constraint::Min(0), // Main Content
])
.split(area);
// Connection Status
let (conn_text, conn_style) = match app.connection_state {
ConnectionState::Disconnected => (
"Disconnected (Press 'c' to refresh/connect)",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
),
ConnectionState::Connected => (
"Connected",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
),
};
let p = Paragraph::new(conn_text)
.style(conn_style)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(p, main_layout[0]);
if app.connection_state == ConnectionState::Disconnected {
return;
}
let content_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(55), // Device & Placement Info
Constraint::Percentage(45), // Battery Info
])
.split(main_layout[1]);
// Left Column: Device Info + Placement
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(content_layout[0]);
// Device Info Table
let rows = vec![
Row::new(vec!["Component", "Firmware", "Serial Number"]).style(Style::default().fg(Color::Yellow)),
Row::new(vec!["Case", &app.software.case_version, &app.hardware.case_serial]),
Row::new(vec!["Left Bud", &app.software.left_version, &app.hardware.left_serial]),
Row::new(vec!["Right Bud", &app.software.right_version, &app.hardware.right_serial]),
];
let table = Table::new(rows, [
Constraint::Percentage(30),
Constraint::Percentage(35),
Constraint::Percentage(35)
])
.block(Block::default().title(" Device Information ").borders(Borders::ALL))
.column_spacing(1);
f.render_widget(table, left_chunks[0]);
// Placement & Connection Table
let place_rows = vec![
Row::new(vec!["Left Placement", &app.runtime.placement_left]),
Row::new(vec!["Right Placement", &app.runtime.placement_right]),
Row::new(vec!["Local Peer", &app.runtime.peer_local]),
Row::new(vec!["Remote Peer", &app.runtime.peer_remote]),
Row::new(vec!["ANC Status", get_setting_val(app, "anc")]),
Row::new(vec!["Multipoint", get_setting_val(app, "multipoint")]),
Row::new(vec!["In-Ear Detection", get_setting_val(app, "ohd")]),
Row::new(vec!["Hold Gestures", &app.gesture_control]),
];
let place_table = Table::new(place_rows, [
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.block(Block::default().title(" Status & Connection ").borders(Borders::ALL))
.column_spacing(1);
f.render_widget(place_table, left_chunks[1]);
// Battery Info - Custom Layout
// We only show Case if known
let mut constraints = vec![];
let show_case = app.battery.case_level.is_some();
// Calculate space needed for each battery item (Text + Gauge)
// We give them 3 lines each: 1 for Text, 1 for Gauge, 1 padding/border
// Actually let's do:
// Case:
// Level: 100% (Charging)
// [||||||||||]
if show_case {
constraints.push(Constraint::Length(4));
}
constraints.push(Constraint::Length(4)); // Left
constraints.push(Constraint::Length(4)); // Right
constraints.push(Constraint::Min(0)); // Spacer
let bat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.margin(1)
.split(content_layout[1]);
let bat_block = Block::default().title(" Battery Status ").borders(Borders::ALL);
f.render_widget(bat_block, content_layout[1]);
let mut current_chunk_idx = 0;
if show_case {
draw_battery_item(f, bat_chunks[current_chunk_idx], "Case", app.battery.case_level, &app.battery.case_status);
current_chunk_idx += 1;
}
draw_battery_item(f, bat_chunks[current_chunk_idx], "Left Bud", app.battery.left_level, &app.battery.left_status);
current_chunk_idx += 1;
draw_battery_item(f, bat_chunks[current_chunk_idx], "Right Bud", app.battery.right_level, &app.battery.right_status);
}
fn get_setting_val<'a>(app: &'a App, key: &str) -> &'a str {
app.settings.iter().find(|s| s.key == key).map(|s| s.value.as_str()).unwrap_or("Unknown")
}
fn draw_battery_item(f: &mut Frame, area: Rect, name: &str, level: Option<u8>, status: &str) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let charging = status.contains("charging") && !status.contains("not charging");
let level_val = level.unwrap_or(0);
let color = if charging { Color::Green } else if level_val < 20 { Color::Red } else { Color::Cyan };
let status_text = if let Some(l) = level {
format!("{} {}% ({})", name, l, status)
} else {
format!("{} Unknown ({})", name, status)
};
let text = Paragraph::new(status_text).style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(text, chunks[0]);
let gauge = Gauge::default()
.gauge_style(Style::default().fg(color))
.ratio(level_val as f64 / 100.0)
.label(""); // No label on gauge itself as requested
f.render_widget(gauge, chunks[1]);
}
fn draw_settings(f: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app.settings.iter().map(|i| {
let val_str = i.value.clone();
let content = Line::from(vec![
Span::styled(format!("{:<40}", i.name), Style::default().fg(Color::White)),
Span::styled(val_str, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]);
ListItem::new(content)
}).collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Settings "))
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol(">> ");
f.render_stateful_widget(list, area, &mut app.settings_state);
}
fn draw_help(f: &mut Frame, app: &App, area: Rect) {
if let Some(err) = &app.last_error {
let p = Paragraph::new(format!("Error: {}", err))
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center);
f.render_widget(p, area);
} else {
let text = "q: Quit | Tab: Switch Tab | c: Check Connection/Refresh | Enter: Toggle/Change Setting";
let p = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(p, area);
}
}