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

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

20
tui/Cargo.toml Normal file
View File

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

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

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

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

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

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

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

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

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

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

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