first commit
This commit is contained in:
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