first commit
This commit is contained in:
67
.github/workflows/ci.yml
vendored
Normal file
67
.github/workflows/ci.yml
vendored
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/.vscode
|
||||
/target
|
||||
2392
Cargo.lock
generated
Normal file
2392
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = [
|
||||
"cli",
|
||||
"libgfps",
|
||||
"libmaestro",
|
||||
"tui",
|
||||
]
|
||||
201
LICENSE-APACHE
Normal file
201
LICENSE-APACHE
Normal 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
21
LICENSE-MIT
Normal 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
93
README.md
Normal 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
24
cli/Cargo.toml
Normal 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
21
cli/build.rs
Normal 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
90
cli/src/bt.rs
Normal 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
317
cli/src/cli.rs
Normal 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
506
cli/src/main.rs
Normal 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
35
docs/Notes.md
Normal 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
22
libgfps/Cargo.toml
Normal 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"] }
|
||||
155
libgfps/examples/gfps_get_battery.rs
Normal file
155
libgfps/examples/gfps_get_battery.rs
Normal 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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
419
libgfps/examples/gfps_listen.rs
Normal file
419
libgfps/examples/gfps_listen.rs
Normal 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
241
libgfps/examples/ring.rs
Normal 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
6
libgfps/src/lib.rs
Normal 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
184
libgfps/src/msg/codec.rs
Normal 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
14
libgfps/src/msg/mod.rs
Normal 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
194
libgfps/src/msg/types.rs
Normal 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
30
libmaestro/Cargo.toml
Normal 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
7
libmaestro/build.rs
Normal 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(())
|
||||
}
|
||||
89
libmaestro/examples/common/mod.rs
Normal file
89
libmaestro/examples/common/mod.rs
Normal 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")
|
||||
}
|
||||
139
libmaestro/examples/maestro_get_battery.rs
Normal file
139
libmaestro/examples/maestro_get_battery.rs
Normal 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)
|
||||
}
|
||||
172
libmaestro/examples/maestro_listen.rs
Normal file
172
libmaestro/examples/maestro_listen.rs
Normal 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(())
|
||||
}
|
||||
152
libmaestro/examples/maestro_read_settings.rs
Normal file
152
libmaestro/examples/maestro_read_settings.rs
Normal 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(())
|
||||
}
|
||||
106
libmaestro/examples/maestro_write_settings.rs
Normal file
106
libmaestro/examples/maestro_write_settings.rs
Normal 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(())
|
||||
}
|
||||
301
libmaestro/proto/maestro_pw.proto
Normal file
301
libmaestro/proto/maestro_pw.proto
Normal 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) {}
|
||||
}
|
||||
87
libmaestro/proto/pw.rpc.packet.proto
Normal file
87
libmaestro/proto/pw.rpc.packet.proto
Normal 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;
|
||||
}
|
||||
72
libmaestro/src/hdlc/codec.rs
Normal file
72
libmaestro/src/hdlc/codec.rs
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
10
libmaestro/src/hdlc/consts.rs
Normal file
10
libmaestro/src/hdlc/consts.rs
Normal 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
109
libmaestro/src/hdlc/crc.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
328
libmaestro/src/hdlc/decoder.rs
Normal file
328
libmaestro/src/hdlc/decoder.rs
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
146
libmaestro/src/hdlc/encoder.rs
Normal file
146
libmaestro/src/hdlc/encoder.rs
Normal 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(),
|
||||
})[..]);
|
||||
}
|
||||
}
|
||||
34
libmaestro/src/hdlc/mod.rs
Normal file
34
libmaestro/src/hdlc/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
154
libmaestro/src/hdlc/varint.rs
Normal file
154
libmaestro/src/hdlc/varint.rs
Normal 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
15
libmaestro/src/lib.rs
Normal 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;
|
||||
115
libmaestro/src/protocol/addr.rs
Normal file
115
libmaestro/src/protocol/addr.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
81
libmaestro/src/protocol/codec.rs
Normal file
81
libmaestro/src/protocol/codec.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
7
libmaestro/src/protocol/mod.rs
Normal file
7
libmaestro/src/protocol/mod.rs
Normal 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"));
|
||||
}
|
||||
73
libmaestro/src/protocol/utils.rs
Normal file
73
libmaestro/src/protocol/utils.rs
Normal 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)
|
||||
}
|
||||
960
libmaestro/src/pwrpc/client.rs
Normal file
960
libmaestro/src/pwrpc/client.rs
Normal 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
194
libmaestro/src/pwrpc/id.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
8
libmaestro/src/pwrpc/mod.rs
Normal file
8
libmaestro/src/pwrpc/mod.rs
Normal 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;
|
||||
236
libmaestro/src/pwrpc/status.rs
Normal file
236
libmaestro/src/pwrpc/status.rs
Normal 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 _)
|
||||
}
|
||||
}
|
||||
31
libmaestro/src/pwrpc/types.rs
Normal file
31
libmaestro/src/pwrpc/types.rs
Normal 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;
|
||||
56
libmaestro/src/pwrpc/utils.rs
Normal file
56
libmaestro/src/pwrpc/utils.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
36
libmaestro/src/service/impls/dosimeter.rs
Normal file
36
libmaestro/src/service/impls/dosimeter.rs
Normal 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, ())
|
||||
}
|
||||
}
|
||||
117
libmaestro/src/service/impls/maestro.rs
Normal file
117
libmaestro/src/service/impls/maestro.rs
Normal 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
|
||||
}
|
||||
8
libmaestro/src/service/impls/mod.rs
Normal file
8
libmaestro/src/service/impls/mod.rs
Normal 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;
|
||||
30
libmaestro/src/service/impls/multipoint.rs
Normal file
30
libmaestro/src/service/impls/multipoint.rs
Normal 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
|
||||
}
|
||||
4
libmaestro/src/service/mod.rs
Normal file
4
libmaestro/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod settings;
|
||||
|
||||
mod impls;
|
||||
pub use impls::{MaestroService, MultipointService, DosimeterService};
|
||||
829
libmaestro/src/service/settings.rs
Normal file
829
libmaestro/src/service/settings.rs
Normal 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
20
tui/Cargo.toml
Normal 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
237
tui/src/app.rs
Normal 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
100
tui/src/bt.rs
Normal 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
418
tui/src/maestro_client.rs
Normal 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
280
tui/src/main.rs
Normal 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
239
tui/src/ui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user