From ff5eddd6dfa76bba1e8ca232cf3b81702af260d3 Mon Sep 17 00:00:00 2001 From: elaxptr Date: Fri, 3 Jul 2026 19:13:42 +0200 Subject: [PATCH 1/4] docs: add approved UX glow-up design spec + reference mockup --- .../specs/2026-07-03-bp1-ux-glowup-design.md | 76 +++ .../specs/assets/bp1-ux-preview.html | 642 ++++++++++++++++++ 2 files changed, 718 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-bp1-ux-glowup-design.md create mode 100644 docs/superpowers/specs/assets/bp1-ux-preview.html diff --git a/docs/superpowers/specs/2026-07-03-bp1-ux-glowup-design.md b/docs/superpowers/specs/2026-07-03-bp1-ux-glowup-design.md new file mode 100644 index 0000000..5181ed7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-bp1-ux-glowup-design.md @@ -0,0 +1,76 @@ +# BP1 Pro Desktop — UX Glow-Up (motion pass) + +**Date:** 2026-07-03 +**Status:** Approved (interactive mockup reviewed & signed off) +**Reference mockup:** `docs/superpowers/specs/assets/bp1-ux-preview.html` (self-contained; the approved visual/behavioral source of truth) + +## Goal + +Elevate the app from "clean and static" to "clean and alive" without changing its +visual identity. Add motion, delight, and micro-interactions on top of the existing +dark/indigo design system. Ship alongside the game-mode feature (issue #3) so the +new toggle debuts with a proper animated control instead of the plain `ON/OFF` text. + +## Non-goals (explicitly out of scope) + +- No new color story, no layout/identity redesign, no glassmorphism/gradients-as-identity. +- No changes to protocol, transport, or Rust backend behavior. +- Settings tab visuals unchanged. +- No new dependencies — motion is CSS + native Web Animations / Solid reactivity only. + +## Principles + +1. **Honor the existing system.** Ground `#0d0d0f`/`#0a0a0c`, cards `#111113`, + borders `#1a1a1e`, dividers `#161618`, accent `#6366f1` (+ `#818cf8`/`#a5b4fc`/`#c7d2fe`), + semantic `#22c55e`/`#ef4444`/`#eab308`. These are fixed; motion never recolors them. +2. **Tasteful, not maximal.** Restraint is the point — subtle glow and spring, not + constant animation. Intensity is tunable during build. +3. **Accessible by default.** Every looping/ambient animation and every non-trivial + transition collapses under `prefers-reduced-motion: reduce`. + +## Motion system (shared tokens) + +Add a small set of shared constants (durations + easings) so motion is consistent: + +- `--ease-spring: cubic-bezier(.34, 1.56, .64, 1)` — knobs, checkmarks, tab marker (overshoot) +- `--ease-out-soft: cubic-bezier(.22, .61, .36, 1)` — panel entrance, ring sweep, bars +- Durations: micro 120–160ms (hover/press), state 240–340ms (toggles, tab swap), + entrance 1.0–1.1s (ring count-up on mount only). + +## Per-component behavior + +| Component | Enhancement | +|---|---| +| **Game mode toggle** (`AncTab`) | Replace `ON/OFF` text button with a sliding pill switch: knob translates with spring, track fills with accent, `state` label flips ON/OFF. When ON: card gets an ambient accent box-shadow + a slow rotating conic-gradient halo (masked border) and the 🎮 icon nudges scale/rotate. A brief scale-pulse fires on click. **Tasteful intensity by default.** | +| **Battery rings** (`HomeTab`/`BudRing`) | On mount (and each time the Battery tab is shown), sweep the progress arc from 0 and count the number up from 0 with an ease-out over ~1.1s. Keep the existing green/red semantic stroke; add a soft `drop-shadow` glow. Charging bolt pulses. Case bar fills from 0% on entrance. | +| **Sidebar marker** (`Sidebar`) | Single active-indicator element that glides (`top`, spring) between icons rather than one-per-button hard cut; add a faint accent glow. Icons lift slightly on hover. | +| **Tab panels** (`App`) | Panel entrance = fade + 8px lift with `--ease-out-soft` on tab change. | +| **Cards / buttons** | Hover: `translateY(-1px)` + border lighten + soft shadow. Press: scale 0.97. Find buttons emit a small accent ripple at the click point. | +| **ANC mode cards** | Selected card keeps existing indigo wash; add a checkmark that pops in with spring, and an accent glow on the selected card. Strength slider expands/collapses (height+opacity) only for ANC/Transparency. | +| **Header** | Very subtle indigo glow that breathes behind the title bar; connection status dot gently pulses. | + +## New material choice + +- **Monospace for numeric readouts** (battery %, strength level, session timer) via a + system mono stack (`ui-monospace, "SF Mono", "Cascadia Code", "Segoe UI Mono", Menlo, monospace`) + with `tabular-nums`. Technical-instrument feel; no webfont, no CDN, no fallback risk. + +## Reduced-motion contract + +Under `prefers-reduced-motion: reduce`: disable the header breathe, status pulse, +charging-bolt flash, and the game-mode rotating halo (static glow remains); rings and +bars jump straight to final value; transition durations collapse to ~0. State is always +reachable without motion. + +## Ships with + +- Version bump (0.2.1 → **0.3.0**, user-facing feature + UX pass) in + `apps/baseus-app/src-tauri/Cargo.toml`, `tauri.conf.json`, `package.json`, and `Cargo.lock`. +- Changelog entry covering game/low-latency mode, the firmware-tolerant ANC ack fix + (issue #3), and the UX motion pass. + +## Verification + +- `tsc --noEmit` clean; `cargo fmt`/`clippy` unaffected (frontend-only + version files). +- Manual: run the app, confirm each interaction animates and that forcing + reduced-motion (OS setting) degrades gracefully. Screenshot before/after for the PR. diff --git a/docs/superpowers/specs/assets/bp1-ux-preview.html b/docs/superpowers/specs/assets/bp1-ux-preview.html new file mode 100644 index 0000000..8c3d488 --- /dev/null +++ b/docs/superpowers/specs/assets/bp1-ux-preview.html @@ -0,0 +1,642 @@ +BP1 Pro — UX Glow-Up Preview + + +
+
+

Baseus Desktop · UX Glow-Up

+

A cooler BP1 Pro — same identity, more life

+

This is a live, clickable preview of the proposed motion pass. Nothing here changes the app's dark/indigo look — it adds the delight: a real animated game-mode toggle, battery rings that count up and breathe, spring-loaded tab transitions, and micro-interactions throughout.

+ It's interactive — switch tabs, flip Low Latency on, pick an ANC mode +
+ +
+
+
+
+ +
+
+
Bass BP1 Pro ANC
+
Connected
+
+ +
+ +
+
+ +
+ +
+ + +
+ +
+
+
Earbuds
+
+
+ +
+ + + + +
0%
+
+ +
LEFT
+
+
+ +
+ + + + +
0%
+
+ +
RIGHT
+
+
+
+ +
+
Case
+
+
+ 🗃️ +
+
+ Case + 62% +
+
+
+ +
+
+
+ +
+
Find Earbuds
+
+ + +
+
+ +
+
Today
+
+ +
+
1:24:07
+
Current session
+
+
+
+
+ + +
+
Noise Control
+
+ + + +
+ +
+
Strength
+
+
Level7 / 10
+ +
LowHigh
+
+
+ +
Game Mode
+
+
+ 🎮 +
Low Latency
Reduces audio delay for gaming
+
OFF
+
+
+
+ + +
+
Equalizer
+
+ + + + +
+
+ + +
+
Settings
+
Launch at login · Low-battery alerts · Session timer — unchanged in this pass.
+
+
+
+
+
+ + + +
+ + +
+ + From 2a7fc6667e8b6478b1d544165470be1a48429cc1 Mon Sep 17 00:00:00 2001 From: elaxptr Date: Fri, 3 Jul 2026 19:36:04 +0200 Subject: [PATCH 2/4] refactor: remove unverified Inspire models, keep BP1-only registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Inspire XH1/XP1/XC1 support was entirely APK-derived and never confirmed on real hardware. Removing it makes the app honest (it only claims what works) and simplifies the codebase: no adaptive ANC modes, no experimental-status banner, no model-switching branches, no HeadphoneBattery (XH1-only) path, no XH1 decoder. The BaseusModel registry is kept (slim, BP1-only) as the extension point for future owner-contributed, hardware-verified models — see BACKLOG.md for the planned Capture Studio + declarative model format that will feed it. Docs: README supported-hardware table trimmed to BP1; re-methodology.md reframed to verified-only contribution (drops the APK-draft path) and corrected to the real BP1 GATT UUIDs (the 02F0 family was an APK decoy). --- BACKLOG.md | 15 +- README.md | 23 +-- apps/baseus-app/src-tauri/src/commands.rs | 6 - apps/baseus-app/src-tauri/src/device.rs | 97 ++-------- apps/baseus-app/src/App.tsx | 57 +----- apps/baseus-app/src/components/AncTab.tsx | 10 +- apps/baseus-app/src/lib/tauri.ts | 19 +- crates/baseus-protocol/src/lib.rs | 4 +- .../baseus-protocol/src/models/inspire_xh1.rs | 180 ------------------ crates/baseus-protocol/src/models/mod.rs | 2 - crates/baseus-protocol/src/types.rs | 83 +------- docs/protocol/inspire-xh1.md | 127 ------------ docs/re-methodology.md | 88 ++++----- 13 files changed, 94 insertions(+), 617 deletions(-) delete mode 100644 crates/baseus-protocol/src/models/inspire_xh1.rs delete mode 100644 docs/protocol/inspire-xh1.md diff --git a/BACKLOG.md b/BACKLOG.md index 03cb8cd..d865490 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,17 +2,16 @@ Post-v1 features, intentionally deferred. -## Experimental model work (Inspire XH1 pilot) +## Protocol capture tooling (in-app Capture Studio) -- [ ] **Verify XH1 BLE UUIDs** — candidate UUIDs from APK analysis (`0000ae0x` family); confirm with nRF Connect on real hardware -- [ ] **Verify XH1 ANC wire format** — opcode 0x68 candidate from APK; confirm mode bytes (0x08/0x09/0x0A for Commute/Outdoor/Indoor) via Frida hook -- [ ] **Implement XH1 ANC SET** — once wire format confirmed, wire `execute_command` for adaptive modes -- [ ] **XH1 HomeTab** — show single headphone battery card instead of L/R/case grid -- [ ] **Promote XH1 from Experimental → Verified** — after ANC + battery confirmed by an owner +The APK-derived Inspire XH1/XP1/XC1 models were removed (never hardware-verified). The +replacement is tooling that lets real owners capture and contribute *verified* support: -## Other Baseus models +- [ ] **In-app Capture Studio** — a dev mode in the desktop app: scan any BLE device, auto-detect notify/write chars, live hex log, guided "toggle ANC now → captured N frames", export a shareable capture bundle. (Owns its own design spec.) +- [ ] **Declarative model format** — define models as data (UUIDs, opcode→event map, frame layout) read by a generic decoder, so adding a device is data + a golden test, not a Rust module. The Capture Studio can auto-draft it. +- [ ] Re-add community-contributed models through the above, each verified on real hardware. -- [ ] Add more models using `tools/extract_apk_model.py` — the workflow is now established; any contributor can add a model as a draft PR +## BP1 Pro ANC remaining work ## BP1 Pro ANC remaining work diff --git a/README.md b/README.md index d8a8e0e..b97dbd5 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,24 @@ the official Baseus Android app. ## Supported hardware -| Model | Status | Battery | ANC | EQ | -|---|---|---|---|---| -| Bass BP1 Pro ANC | ✅ Verified | ✅ L/R/case | ✅ 3-mode + strength | ✅ 4 presets | -| Inspire XH1 | 🧪 Experimental | ⚠ Untested | ⚠ 5-mode adaptive (untested) | — | -| Inspire XP1 | 🧪 Experimental | ⚠ Untested | ⚠ 3-mode (untested) | ⚠ Untested | -| Inspire XC1 | 🧪 Experimental | ⚠ Untested | ⚠ 3-mode (untested) | ⚠ Untested | - -**Experimental** means the protocol was extracted from the Baseus Android APK via static analysis — -no physical device has been tested. If you own any of these, install the app and -[report what works](https://github.com/elaxptr/baseus-desktop/issues). See -[docs/protocol/inspire-xh1.md](docs/protocol/inspire-xh1.md) for the full protocol draft. +| Model | Status | Battery | ANC | EQ | Game mode | +|---|---|---|---|---|---| +| Bass BP1 Pro ANC | ✅ Verified | ✅ L/R/case | ✅ 3-mode + strength | ✅ 4 presets | ✅ Low-latency toggle | + +Only hardware-verified models are supported. Earlier drafts included Inspire XH1/XP1/XC1 +support extracted from the Baseus Android APK, but since none was ever confirmed on a real +device it has been removed rather than ship promises we can't back. + +**Own a different Baseus model?** Adding it is the goal — see +[docs/re-methodology.md](docs/re-methodology.md) for how to capture your device's protocol +and contribute it back. Protocol capture tooling to make this much easier is on the roadmap. ## Features - Live L / R / case battery with charge state indicators - Session timer (time since buds connected) - ANC mode switching (Off / Active Noise Cancellation / Transparency) with strength slider +- Game / low-latency mode toggle - EQ preset selection (Balanced / Bass Boost / Voice / Clear) - Find-my-buds (plays a tone on one earbud) - Low-battery desktop notifications diff --git a/apps/baseus-app/src-tauri/src/commands.rs b/apps/baseus-app/src-tauri/src/commands.rs index 966392b..20dc6ca 100644 --- a/apps/baseus-app/src-tauri/src/commands.rs +++ b/apps/baseus-app/src-tauri/src/commands.rs @@ -15,12 +15,6 @@ pub fn set_anc_mode( "off" => AncMode::Off, "anc" => AncMode::Anc, "transparency" => AncMode::Transparency, - // XH1 adaptive modes — command is accepted but will no-op in execute_command - // (wire format unverified). Logged server-side for debugging. - "adaptive_self" => AncMode::AdaptiveSelf, - "adaptive_indoor" => AncMode::AdaptiveIndoor, - "adaptive_outdoor" => AncMode::AdaptiveOutdoor, - "adaptive_commute" => AncMode::AdaptiveCommute, other => return Err(format!("unknown mode: {other}")), }; let byte = level.unwrap_or(0x68); diff --git a/apps/baseus-app/src-tauri/src/device.rs b/apps/baseus-app/src-tauri/src/device.rs index ecacf81..884b5c0 100644 --- a/apps/baseus-app/src-tauri/src/device.rs +++ b/apps/baseus-app/src-tauri/src/device.rs @@ -2,8 +2,8 @@ use std::time::Duration; use baseus_protocol::{ framing::Frame, - models::{bp1_pro_anc::Bp1ProAnc, inspire_xh1::InspireXh1}, - types::{AncMode, BaseusModel, DeviceEvent, EqPreset, ModelStatus}, + models::bp1_pro_anc::Bp1ProAnc, + types::{AncMode, BaseusModel, DeviceEvent, EqPreset}, }; use baseus_transport::win::ble::GattTransport; use serde::Serialize; @@ -45,7 +45,6 @@ pub fn command_channel() -> (CommandSender, CommandReceiver) { #[derive(Debug, Clone, Serialize)] pub struct ModelInfo { pub name: &'static str, - pub status: ModelStatus, } pub async fn run_loop(app: AppHandle, mut cmd_rx: CommandReceiver) { @@ -87,7 +86,6 @@ pub async fn run_loop(app: AppHandle, mut cmd_rx: CommandReceiver) { "model-info", &ModelInfo { name: connected_model.display_name(), - status: connected_model.status(), }, ); @@ -130,11 +128,7 @@ async fn notification_loop( tracing::debug!("raw notification: {}", hex(&data)); if let Ok(frame) = Frame::decode(&data) { let event = match model { - // XP1/XC1 share the same earbud protocol as BP1 (same 0x0C ANC - // family, same battery framing) — APK-extracted, unverified. - BaseusModel::Bp1ProAnc - | BaseusModel::InspireXp1 - | BaseusModel::InspireXc1 => { + BaseusModel::Bp1ProAnc => { if frame.cmd == 0x34 { // Ack payload semantics vary by firmware (issue #3): // some units send a flat AA 34 01 for every ANC command, @@ -153,15 +147,6 @@ async fn notification_loop( } } } - BaseusModel::InspireXh1 => { - match InspireXh1::decode_frame(&frame) { - Ok(ev) => Some(ev), - Err(e) => { - tracing::debug!("unhandled frame cmd={:#04x}: {e}", frame.cmd); - None - } - } - } }; if let Some(ev) = event { maybe_alert_battery(app, &ev, &mut thresholds); @@ -236,56 +221,28 @@ async fn execute_command( model: BaseusModel, ) -> Result<(), String> { let bytes: Vec = match (cmd, model) { - // BP1 / XP1 / XC1 — verified for BP1; XP1/XC1 assumed same protocol (APK-extracted). - ( - DeviceCommand::SetAncMode(AncMode::Off, _), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x00, 0xFF], - ( - DeviceCommand::SetAncMode(AncMode::Anc, level), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x01, *level], - ( - DeviceCommand::SetAncMode(AncMode::Transparency, level), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x34, 0x02, *level], - ( - DeviceCommand::SetEqPreset(preset), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x43, preset.to_byte()], - // Game/low-latency mode — verified for BP1 over both SPP and BLE (issue #3). - ( - DeviceCommand::SetGameMode(on), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x24, u8::from(*on)], - ( - DeviceCommand::FindEarbud(Side::Left), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x10, 0x00, 0x01], - ( - DeviceCommand::FindEarbud(Side::Right), - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1, - ) => vec![0xBA, 0x10, 0x01, 0x01], - // Inspire XH1 — setting ANC/EQ not yet supported (wire format unverified). - (DeviceCommand::SetAncMode(mode, _), BaseusModel::InspireXh1) => { - tracing::info!("XH1 ANC set for {mode:?} not yet supported — wire format unverified"); - return Ok(()); + // BP1 Pro ANC — verified on hardware. + (DeviceCommand::SetAncMode(AncMode::Off, _), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x00, 0xFF] } - (DeviceCommand::SetEqPreset(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 EQ preset not yet supported — wire format unverified"); - return Ok(()); + (DeviceCommand::SetAncMode(AncMode::Anc, level), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x01, *level] } - (DeviceCommand::SetGameMode(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 game mode not yet supported — wire format unverified"); - return Ok(()); + (DeviceCommand::SetAncMode(AncMode::Transparency, level), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x34, 0x02, *level] + } + (DeviceCommand::SetEqPreset(preset), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x43, preset.to_byte()] + } + // Game/low-latency mode — verified for BP1 over both SPP and BLE (issue #3). + (DeviceCommand::SetGameMode(on), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x24, u8::from(*on)] } - (DeviceCommand::FindEarbud(_), BaseusModel::InspireXh1) => { - tracing::info!("XH1 find not yet supported"); - return Ok(()); + (DeviceCommand::FindEarbud(Side::Left), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x10, 0x00, 0x01] } - // Adaptive ANC modes — only used by XH1; unreachable for earbud models. - (DeviceCommand::SetAncMode(_, _), _) => { - return Err("ANC mode not supported for this model".to_string()); + (DeviceCommand::FindEarbud(Side::Right), BaseusModel::Bp1ProAnc) => { + vec![0xBA, 0x10, 0x01, 0x01] } }; transport.send(&bytes).await.map_err(|e| e.to_string()) @@ -325,18 +282,6 @@ fn maybe_alert_battery(app: &AppHandle, event: &DeviceEvent, thresholds: &mut Ba thresholds.left_was_ok = left_now_ok; thresholds.right_was_ok = right_now_ok; } - DeviceEvent::HeadphoneBatteryUpdate(h) => { - let now_ok = h.pct >= LOW || h.pct == 0; - if thresholds.left_was_ok && !now_ok { - let _ = app - .notification() - .builder() - .title("Baseus — Headphone low") - .body(format!("{}% remaining", h.pct)) - .show(); - } - thresholds.left_was_ok = now_ok; - } DeviceEvent::CaseUpdate(c) => { let case_now_ok = c.case_pct >= LOW || c.case_pct == 0; if thresholds.case_was_ok && !case_now_ok { diff --git a/apps/baseus-app/src/App.tsx b/apps/baseus-app/src/App.tsx index 28642c3..3600d9f 100644 --- a/apps/baseus-app/src/App.tsx +++ b/apps/baseus-app/src/App.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'; +import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import Sidebar, { type Tab } from './components/Sidebar'; import HomeTab from './components/HomeTab'; import AncTab from './components/AncTab'; @@ -24,7 +24,6 @@ import { startTimer, stopTimer, useElapsed } from './lib/timer'; type ConnStatus = 'connected' | 'connecting' | 'disconnected'; const BP1_ANC_MODES: AncMode[] = ['off', 'anc', 'transparency']; -const XH1_ANC_MODES: AncMode[] = ['adaptive_self', 'adaptive_indoor', 'adaptive_outdoor', 'adaptive_commute']; export default function App() { const [status, setStatus] = createSignal('connecting'); @@ -41,16 +40,8 @@ export default function App() { const [eqPreset, setEqPresetSignal] = createSignal('balanced'); const [updateVersion, setUpdateVersion] = createSignal(null); - const isExperimental = createMemo(() => modelInfo()?.status === 'experimental'); const connectedModelName = createMemo(() => modelInfo()?.name ?? 'Bass BP1 Pro ANC'); - const supportedAncModes = createMemo(() => { - const info = modelInfo(); - if (!info) return BP1_ANC_MODES; - if (info.name === 'Inspire XH1') return XH1_ANC_MODES; - return BP1_ANC_MODES; - }); - // Game mode is verified for the BP1 family; XH1 wire format is unknown. - const supportsGameMode = createMemo(() => modelInfo()?.name !== 'Inspire XH1'); + const supportedAncModes = createMemo(() => BP1_ANC_MODES); onMount(async () => { const unlisteners: Array<() => void> = []; @@ -64,10 +55,6 @@ export default function App() { pushRight(e.data.right_pct); setLeftCharging(e.data.left_charging); setRightCharging(e.data.right_charging); - } else if (e.type === 'headphone_battery_update') { - // XH1: single battery — display in left slot; right/case stay at 0. - pushLeft(e.data.pct); - setLeftCharging(e.data.charging); } else if (e.type === 'case_update') { pushCase(e.data.case_pct); setCaseCharging(e.data.case_charging); @@ -89,12 +76,7 @@ export default function App() { onModelInfo((info) => { setModelInfo(info); - // Reset mode to a sensible default for the newly connected model. - if (info.name === 'Inspire XH1') { - setAncModeSignal('adaptive_self'); - } else { - setAncModeSignal('off'); - } + setAncModeSignal('off'); }).then((fn) => unlisteners.push(fn)); onUpdateAvailable((version) => setUpdateVersion(version)) @@ -191,37 +173,6 @@ export default function App() { - {/* Experimental model warning */} - -
- - - Experimental — APK-derived protocol, untested on real hardware.{' '} - - Report what works - - -
-
- {/* Body: sidebar + content */}
@@ -252,7 +203,7 @@ export default function App() { level={ancLevel()} supportedModes={supportedAncModes()} gameMode={gameMode()} - showGameMode={supportsGameMode()} + showGameMode={true} onMode={handleAnc} onLevel={handleLevel} onLevelCommit={handleLevelCommit} diff --git a/apps/baseus-app/src/components/AncTab.tsx b/apps/baseus-app/src/components/AncTab.tsx index dad09e4..dc113dd 100644 --- a/apps/baseus-app/src/components/AncTab.tsx +++ b/apps/baseus-app/src/components/AncTab.tsx @@ -14,13 +14,9 @@ interface Props { } const MODE_META: Record = { - off: { icon: '🔇', name: 'Off', desc: 'Passthrough — no processing' }, - anc: { icon: '🎧', name: 'Active Noise Cancellation', desc: 'Blocks ambient sound' }, - transparency: { icon: '🌬️', name: 'Transparency', desc: 'Lets ambient sound in' }, - adaptive_self: { icon: '👤', name: 'Adaptive — Self', desc: 'Calibrated to your hearing profile' }, - adaptive_indoor: { icon: '🏠', name: 'Adaptive — Indoor', desc: 'Optimised for indoor environments' }, - adaptive_outdoor: { icon: '🌳', name: 'Adaptive — Outdoor', desc: 'Handles wind and open spaces' }, - adaptive_commute: { icon: '🚇', name: 'Adaptive — Commute', desc: 'Transit noise suppression' }, + off: { icon: '🔇', name: 'Off', desc: 'Passthrough — no processing' }, + anc: { icon: '🎧', name: 'Active Noise Cancellation', desc: 'Blocks ambient sound' }, + transparency: { icon: '🌬️', name: 'Transparency', desc: 'Lets ambient sound in' }, }; // Level slider only applies to BP1 ANC/Transparency modes. diff --git a/apps/baseus-app/src/lib/tauri.ts b/apps/baseus-app/src/lib/tauri.ts index b922775..6331d7e 100644 --- a/apps/baseus-app/src/lib/tauri.ts +++ b/apps/baseus-app/src/lib/tauri.ts @@ -8,11 +8,6 @@ export interface BatteryState { right_charging: boolean; } -export interface HeadphoneBattery { - pct: number; - charging: boolean; -} - export interface CaseState { case_pct: number; case_charging: boolean; @@ -23,28 +18,16 @@ export interface WearState { right_in_ear: boolean; } -export type AncMode = - | 'off' - | 'anc' - | 'transparency' - // Inspire XH1 adaptive modes — APK-extracted, unverified wire format - | 'adaptive_self' - | 'adaptive_indoor' - | 'adaptive_outdoor' - | 'adaptive_commute'; +export type AncMode = 'off' | 'anc' | 'transparency'; export type EqPreset = 'balanced' | 'bass_boost' | 'voice' | 'clear'; -export type ModelStatus = 'verified' | 'experimental'; - export interface ModelInfo { name: string; - status: ModelStatus; } export type DeviceEvent = | { type: 'battery_update'; data: BatteryState } - | { type: 'headphone_battery_update'; data: HeadphoneBattery } | { type: 'case_update'; data: CaseState } | { type: 'anc_mode_update'; data: AncMode } | { type: 'game_mode_update'; data: boolean } diff --git a/crates/baseus-protocol/src/lib.rs b/crates/baseus-protocol/src/lib.rs index b9e58ac..0609567 100644 --- a/crates/baseus-protocol/src/lib.rs +++ b/crates/baseus-protocol/src/lib.rs @@ -3,6 +3,4 @@ pub mod models; pub mod types; pub use framing::Frame; -pub use types::{ - AncMode, BaseusModel, BatteryState, CaseState, DeviceEvent, HeadphoneBattery, ModelStatus, -}; +pub use types::{AncMode, BaseusModel, BatteryState, CaseState, DeviceEvent}; diff --git a/crates/baseus-protocol/src/models/inspire_xh1.rs b/crates/baseus-protocol/src/models/inspire_xh1.rs deleted file mode 100644 index 9b65940..0000000 --- a/crates/baseus-protocol/src/models/inspire_xh1.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Baseus Inspire XH1 — APK-EXTRACTED, UNVERIFIED. -//! -//! All opcodes and frame shapes below are derived from static analysis of the Baseus -//! Android APK (bytecode-dump2.txt). No physical XH1 device has been used to verify them. -//! -//! Sources: -//! - Feature opcode list: EarFunctionManager2.f("Baseus Inspire XH1") → lines 4079–4100 -//! - Query opcode list: EarFunctionManager2.i("Baseus Inspire XH1") → lines 4163–4179 -//! - ANC mode IDs: NoiseTypeBean constructors → lines 5699–5751 -//! - BLE UUIDs: apk-analysis.log classes6.dex (0000ae0x family) -//! -//! To add verified captures and promote this to Verified status, see: -//! docs/protocol/inspire-xh1.md and docs/re-methodology.md - -use crate::{ - models::DecodeError, - types::{AncMode, DeviceEvent, HeadphoneBattery}, - Frame, -}; - -pub struct InspireXh1; - -impl InspireXh1 { - /// Decode a GATT notification frame from the Inspire XH1. - /// - /// Known / candidate opcodes (APK-extracted): - /// AA 02 … → battery report (assumed same as BP1; layout unverified) - /// AA 68 … → ANC mode notification (candidate opcode 0x68 from EarFunctionManager2) - pub fn decode_frame(frame: &Frame) -> Result { - match frame.cmd { - // Battery: assumed same framing as BP1 Pro ANC (AA 02 …). - // APK-EXTRACTED, UNVERIFIED. - 0x02 => Self::decode_battery(&frame.payload), - // ANC notification: opcode 0x68 appears in both the feature list and query list - // for XH1 in EarFunctionManager2. Payload byte is the noise mode ID. - // APK-EXTRACTED, UNVERIFIED. - 0x68 => Self::decode_anc(&frame.payload), - other => Err(DecodeError::UnknownOpcode(other)), - } - } - - fn decode_battery(payload: &[u8]) -> Result { - // Hypothesis A: AA 02 [pct: u8] [charging_flag: u8] - // XH1 is an over-ear headphone — no L/R bud split, no case battery. - // APK-EXTRACTED, UNVERIFIED. - if payload.is_empty() { - return Err(DecodeError::PayloadTooShort { - opcode: 0x02, - need: 1, - got: 0, - }); - } - Ok(DeviceEvent::HeadphoneBatteryUpdate(HeadphoneBattery { - pct: payload[0], - charging: payload.get(1).copied().unwrap_or(0) != 0, - })) - } - - fn decode_anc(payload: &[u8]) -> Result { - // Mode IDs from NoiseTypeBean constructor (EarFunctionManager2, lines 5699–5751): - // Commute = 0x08 (8), Outdoor = 0x09 (9), Indoor = 0x0A (10) - // Self and Off wire bytes are unknown — they fall through to AdaptiveSelf. - // APK-EXTRACTED, UNVERIFIED. - let mode_byte = payload.first().copied().unwrap_or(0); - let mode = match mode_byte { - 0x08 => AncMode::AdaptiveCommute, - 0x09 => AncMode::AdaptiveOutdoor, - 0x0A => AncMode::AdaptiveIndoor, - _ => AncMode::AdaptiveSelf, - }; - Ok(DeviceEvent::AncModeUpdate(mode)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Frame; - - fn decode(raw: &[u8]) -> Result { - InspireXh1::decode_frame(&Frame::decode(raw).unwrap()) - } - - // These tests assert that the APK-derived decoder logic is internally consistent. - // They do NOT prove the protocol is correct — they prove the code matches the doc. - - #[test] - fn battery_single_byte_decodes() { - // Hypothesis A: AA 02 [pct] [charging_flag] - let ev = decode(&[0xAA, 0x02, 0x50, 0x00]).unwrap(); - assert_eq!( - ev, - DeviceEvent::HeadphoneBatteryUpdate(HeadphoneBattery { - pct: 80, - charging: false, - }) - ); - } - - #[test] - fn battery_charging_flag_set() { - let ev = decode(&[0xAA, 0x02, 0x32, 0x01]).unwrap(); - assert_eq!( - ev, - DeviceEvent::HeadphoneBatteryUpdate(HeadphoneBattery { - pct: 50, - charging: true, - }) - ); - } - - #[test] - fn battery_too_short_is_error() { - let frame = Frame { - cmd: 0x02, - payload: vec![], - }; - assert!(matches!( - InspireXh1::decode_frame(&frame), - Err(DecodeError::PayloadTooShort { - opcode: 0x02, - need: 1, - got: 0 - }) - )); - } - - #[test] - fn anc_indoor_decodes() { - // AA 68 0A — Indoor mode (NoiseTypeBean ID 0x0A) - let ev = decode(&[0xAA, 0x68, 0x0A]).unwrap(); - assert_eq!(ev, DeviceEvent::AncModeUpdate(AncMode::AdaptiveIndoor)); - } - - #[test] - fn anc_outdoor_decodes() { - // AA 68 09 — Outdoor mode (NoiseTypeBean ID 0x09) - let ev = decode(&[0xAA, 0x68, 0x09]).unwrap(); - assert_eq!(ev, DeviceEvent::AncModeUpdate(AncMode::AdaptiveOutdoor)); - } - - #[test] - fn anc_commute_decodes() { - // AA 68 08 — Commute mode (NoiseTypeBean ID 0x08) - let ev = decode(&[0xAA, 0x68, 0x08]).unwrap(); - assert_eq!(ev, DeviceEvent::AncModeUpdate(AncMode::AdaptiveCommute)); - } - - #[test] - fn anc_unknown_falls_back_to_self() { - // Any other mode byte (Self, Off, Custom) falls back to AdaptiveSelf. - let ev = decode(&[0xAA, 0x68, 0x00]).unwrap(); - assert_eq!(ev, DeviceEvent::AncModeUpdate(AncMode::AdaptiveSelf)); - } - - #[test] - fn unknown_opcode_is_error() { - let frame = Frame { - cmd: 0x99, - payload: vec![], - }; - assert!(matches!( - InspireXh1::decode_frame(&frame), - Err(DecodeError::UnknownOpcode(0x99)) - )); - } - - // This test requires a physical XH1 to provide a real notification capture. - // It exists as a placeholder — fill in the raw bytes and remove `#[ignore]` once confirmed. - #[test] - #[ignore = "requires XH1 hardware — update raw bytes from a real nRF Connect capture"] - fn real_battery_capture_decodes() { - // Replace 0xAA, 0x02, 0x??, 0x?? with an actual capture. - let ev = decode(&[0xAA, 0x02, 0x64, 0x00]).unwrap(); - assert!(matches!( - ev, - DeviceEvent::HeadphoneBatteryUpdate(HeadphoneBattery { pct: 100, .. }) - )); - } -} diff --git a/crates/baseus-protocol/src/models/mod.rs b/crates/baseus-protocol/src/models/mod.rs index 439f517..cd17f45 100644 --- a/crates/baseus-protocol/src/models/mod.rs +++ b/crates/baseus-protocol/src/models/mod.rs @@ -9,7 +9,5 @@ pub enum DecodeError { } pub mod bp1_pro_anc; -pub mod inspire_xh1; pub use bp1_pro_anc::Bp1ProAnc; -pub use inspire_xh1::InspireXh1; diff --git a/crates/baseus-protocol/src/types.rs b/crates/baseus-protocol/src/types.rs index 18ae581..75c828b 100644 --- a/crates/baseus-protocol/src/types.rs +++ b/crates/baseus-protocol/src/types.rs @@ -15,41 +15,20 @@ pub struct BatteryState { pub right_charging: bool, } -/// Single-unit battery for over-ear headphones (no L/R split, no case). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct HeadphoneBattery { - pub pct: u8, - pub charging: bool, -} - #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AncMode { - // BP1 Pro ANC / Inspire XP1 / Inspire XC1 — verified for BP1 + // BP1 Pro ANC — verified on hardware. Off, Anc, Transparency, - // Inspire XH1 adaptive modes — APK-extracted, unverified wire format. - // Mode IDs from NoiseTypeBean: Commute=0x08, Outdoor=0x09, Indoor=0x0A. - AdaptiveCommute, - AdaptiveOutdoor, - AdaptiveIndoor, - AdaptiveSelf, } impl AncMode { /// ANC modes supported by a given model (for UI filtering). pub fn supported_by(model: BaseusModel) -> &'static [AncMode] { match model { - BaseusModel::Bp1ProAnc | BaseusModel::InspireXp1 | BaseusModel::InspireXc1 => { - &[AncMode::Off, AncMode::Anc, AncMode::Transparency] - } - BaseusModel::InspireXh1 => &[ - AncMode::AdaptiveSelf, - AncMode::AdaptiveIndoor, - AncMode::AdaptiveOutdoor, - AncMode::AdaptiveCommute, - ], + BaseusModel::Bp1ProAnc => &[AncMode::Off, AncMode::Anc, AncMode::Transparency], } } } @@ -91,7 +70,6 @@ pub struct WearState { #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum DeviceEvent { BatteryUpdate(BatteryState), - HeadphoneBatteryUpdate(HeadphoneBattery), CaseUpdate(CaseState), AncModeUpdate(AncMode), /// Game/low-latency mode — independent toggle, not a mutually-exclusive ANC state. @@ -108,49 +86,24 @@ pub struct CaseState { pub case_charging: bool, } -/// Whether a model's support is owner-verified or APK-derived and untested. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ModelStatus { - Verified, - Experimental, -} - +/// Registry of supported Baseus models. +/// +/// Only hardware-verified models live here. The enum is intentionally kept as a +/// registry (rather than flattened to BP1-only) so future owner-contributed, +/// verified models can be added without reworking the dispatch structure. #[derive(Debug, Clone, Copy, PartialEq)] pub enum BaseusModel { Bp1ProAnc, - InspireXh1, - /// In-ear earbuds — APK-extracted protocol, same 0x0C ANC family as BP1. - InspireXp1, - /// Clip-on earphones — APK-extracted protocol, same 0x0C ANC family as BP1. - InspireXc1, } impl BaseusModel { pub fn all() -> &'static [BaseusModel] { - &[ - BaseusModel::Bp1ProAnc, - BaseusModel::InspireXh1, - BaseusModel::InspireXp1, - BaseusModel::InspireXc1, - ] - } - - pub fn status(self) -> ModelStatus { - match self { - BaseusModel::Bp1ProAnc => ModelStatus::Verified, - BaseusModel::InspireXh1 | BaseusModel::InspireXp1 | BaseusModel::InspireXc1 => { - ModelStatus::Experimental - } - } + &[BaseusModel::Bp1ProAnc] } pub fn display_name(self) -> &'static str { match self { BaseusModel::Bp1ProAnc => "Bass BP1 Pro ANC", - BaseusModel::InspireXh1 => "Inspire XH1", - BaseusModel::InspireXp1 => "Inspire XP1", - BaseusModel::InspireXc1 => "Inspire XC1", } } @@ -159,30 +112,14 @@ impl BaseusModel { pub fn advertising_names(self) -> &'static [&'static str] { match self { BaseusModel::Bp1ProAnc => &["Bass BP1 Pro"], - // APK-EXTRACTED: exact string keys from EarFunctionManager2 dispatch. - // Short aliases cover devices that advertise without trailing model number. - BaseusModel::InspireXh1 => &["Baseus Inspire XH1", "Baseus Inspire XH"], - BaseusModel::InspireXp1 => &["Baseus Inspire XP1", "Baseus Inspire XP"], - BaseusModel::InspireXc1 => &["Baseus Inspire XC1", "Baseus Inspire XC"], } } /// GATT (notify_uuid, write_uuid) for BLE control. - /// BP1 values confirmed via nRF Connect. Inspire values APK-extracted, unverified. + /// Confirmed via nRF Connect on a physical unit. pub fn gatt_uuids(self) -> (&'static str, &'static str) { match self { - BaseusModel::Bp1ProAnc => ( - "654b749c-e37f-ae1f-ebab-40ca133e3690", - "ee684b1a-1e9b-ed3e-ee55-f894667e92ac", - ), - // APK-EXTRACTED, UNVERIFIED — Bluetrum CCSDK (com.bluetrum.ccsdk) from classes4.dex. - // Service UUID: 0000fae0-0000-1000-8000-00805f9b34fb. - // fae1=TX/notify (device→host), fae2=RX/write (host→device). - // The ae01/ae02 UUIDs (JieLi BT OTA SDK, classes6.dex) are for firmware OTA only. - BaseusModel::InspireXh1 | BaseusModel::InspireXp1 | BaseusModel::InspireXc1 => ( - "0000fae1-0000-1000-8000-00805f9b34fb", - "0000fae2-0000-1000-8000-00805f9b34fb", - ), + BaseusModel::Bp1ProAnc => (ble_uuids::NOTIFY, ble_uuids::WRITE), } } diff --git a/docs/protocol/inspire-xh1.md b/docs/protocol/inspire-xh1.md deleted file mode 100644 index 853c276..0000000 --- a/docs/protocol/inspire-xh1.md +++ /dev/null @@ -1,127 +0,0 @@ -# Baseus Inspire XH1 — Packet Table (APK-DERIVED, UNVERIFIED) - -> **Status:** Experimental — all opcodes and UUIDs extracted from static APK analysis. -> No physical device has been used to verify this table. Fields marked ⚠ need live confirmation. -> See "How to verify" at the bottom if you own an XH1. - -## Device identification - -| Field | Value | -|---|---| -| App product name | "Baseus Inspire XH1" (seen in APK bytecode-dump2.txt) | -| Chip vendor | Unknown — likely Qualcomm or Beken (⚠ unverified) | -| BT profiles | BLE GATT (control); Classic A2DP + HFP (audio) | -| App package | `com.baseus.intelligent` | -| Control protocol | Proprietary BLE GATT | -| BLE device name | `Baseus Inspire XH1` (from `EarFunctionManager2` dispatch key) | -| APK dispatch class | `com.control_center.intelligent.view.activity.headphones.manager.EarFunctionManager2` | - -## BLE GATT endpoints ⚠ UNVERIFIED - -Candidate UUIDs from `apk-analysis.log` classes6.dex (`0000ae0x` family). -classes6.dex also contains `00001101`/`0000110b`/`0000111e` Classic BT profiles, consistent -with a headphone that does BLE control + Classic audio. - -| Role | UUID (candidate) | Source | -|---|---|---| -| Service | `0000ae00-0000-1000-8000-00805f9b34fb` | APK classes6.dex | -| Write | `0000ae01-0000-1000-8000-00805f9b34fb` | APK classes6.dex | -| Notify | `0000ae02-0000-1000-8000-00805f9b34fb` | APK classes6.dex | - -Alternative UUID family if ae00 fails — classes4.dex (`0000fae0` family): - -| Role | UUID (alt candidate) | -|---|---| -| Service | `0000fae0-0000-1000-8000-00805f9b34fb` | -| Write | `0000fae1-0000-1000-8000-00805f9b34fb` | -| Notify | `0000fae2-0000-1000-8000-00805f9b34fb` | - -**To confirm:** Connect with nRF Connect, tap the XH1 device, list services. Report which UUID family is present. - -## Frame format ⚠ assumed - -Assumed same as BP1 Pro ANC (shared Baseus protocol base): - -``` -[ 0xAA ] [ CMD ] [ payload... ] ← device → app (notify char) -[ 0xBA ] [ CMD ] [ payload... ] ← app → device (write char) -``` - -No length or CRC in BLE GATT PDU; frame boundary = GATT notification/write boundary. - -## Battery ⚠ UNVERIFIED - -Assumed CMD `0x02` (same opcode as BP1). XH1 is over-ear, single unit — payload may differ. - -Hypothesis A (single battery, 2-byte payload): -``` -AA 02 [pct: u8] [charging_flag: u8] -``` - -Hypothesis B (L/R headband drivers, same format as earbuds): -``` -AA 02 [left_driver_pct: u8] 0x00 [right_driver_pct: u8] 0x01 -``` - -To confirm: pair XH1, open nRF Connect, subscribe to notify characteristic, charge/discharge while observing `0xAA 0x02` frames. - -## APK feature opcode lists (NOT wire bytes) - -These are app-internal feature-type integers from `EarFunctionManager2.f("Baseus Inspire XH1")`. -They identify which UI panels to show — they are **not** BLE wire opcodes. - -``` -f() returns: [0x65=101, 0x6B=107, 0x00, 0x3E3=995, 0x6A=106, 0x68=104, 0x0A=10] -i() returns: [0x3E2=994, 0x3E6=998, 0x07, 0x03, 0x6A=106, 0x68=104] -``` - -Source: `bytecode-dump2.txt` lines 4079–4100 (method `f`) and 4163–4179 (method `i`). - -The values 0x68 (104) and 0x6A (106) appear in both lists and are small enough to be single-byte -BLE opcodes. **Candidates only — must be confirmed with hardware.** - -## ANC modes ⚠ UNVERIFIED - -XH1 has 5 adaptive noise modes (not BP1's 3-mode Off/ANC/Transparency). -Mode labels and internal IDs from `NoiseTypeBean` constructors in `EarFunctionManager2` -(`bytecode-dump2.txt` lines 5699–5751): - -| Mode label | Internal ID | Wire byte (⚠ candidate) | -|---|---|---| -| Self (own hearing profile) | unknown | unknown | -| Indoor | `0x0A` (10) | `0x0A`? | -| Outdoor | `0x09` (9) | `0x09`? | -| Commute | `0x08` (8) | `0x08`? | -| Custom | variable (from `NoiseReduceDataModel.b()`) | unknown | - -**Hypothesis:** The mode IDs from `NoiseTypeBean` (8/9/10) may be the wire payload bytes for -an ANC SET command with format `BA [anc_opcode] [mode_id]`. The ANC opcode candidate is `0x68` -(appears in both feature and query lists). - -ANC RX (device → app notification): `AA 68 [mode_id]`? (⚠ all unverified). - -To confirm: hook `OutputStream.write` via `docs/frida/socket-trace.js` while toggling -noise modes in the Baseus app. - -## Sibling models (same Inspire family, untested) - -| Model | Notes | -|---|---| -| Baseus Inspire XP1 | Appears in same dispatch branches; may share protocol | -| Baseus Inspire XC1 | Appears in same dispatch branches; unknown feature delta | - -## How to verify (if you own an XH1) - -1. Install the app, let it connect to your XH1. -2. Open nRF Connect → find `Baseus Inspire XH1` device. -3. List GATT services — identify the service, write, and notify UUIDs. Update this table. -4. Subscribe to notify characteristic. Toggle noise mode in the Baseus app and capture the hex bytes. -5. Use nRF Connect's write UI to send `AA 02` manually and observe the device response. -6. Run `docs/frida/socket-trace.js` via Frida for write-side captures. -7. Open a PR updating this table and `crates/baseus-protocol/src/models/inspire_xh1.rs`. - -## Source files - -- APK dump: `docs/protocol/captures/bytecode-dump.txt`, `bytecode-dump2.txt` -- UUID inventory: `docs/protocol/captures/apk-analysis.log` -- Extraction script: `tools/extract_apk_model.py --name "Baseus Inspire XH1"` diff --git a/docs/re-methodology.md b/docs/re-methodology.md index 33607ba..33ec327 100644 --- a/docs/re-methodology.md +++ b/docs/re-methodology.md @@ -56,59 +56,41 @@ Open in Wireshark. Filter: `btrfcomm || avctp || a2dp` ``` ## What to look for -- **Bluetooth profile**: The BP1 Pro ANC uses **BLE GATT** (not RFCOMM) for all runtime - control. Filter Wireshark for `btatt` and look for ATT writes to the Bluetrum CCSDK - service `02F00000-0000-0000-0000-00000000FE00`. -- **Write characteristic**: `02F00000-0000-0000-0000-00000000FF01` (app → device commands) -- **Notify characteristic**: `02F00000-0000-0000-0000-00000000FF02` (device → app events) -- **Auth handshake**: Look for the challenge-response exchange immediately after GATT - service discovery. The pattern involves a DevAuthChallenge and DevAuthResponse. -- **Battery command**: Triggered by opening the case lid; look for notify on `FF02` with - three power level bytes (left, right, case) and three charging-state bytes. -- **ANC command**: Toggling ANC from the UI causes a write to `FF01`; command byte appears - to be `0x0C` based on `BleCommandUtil.resolveModify0C` method name in the APK. -- **Frida note**: Java bridge does not work on x86_64 Android 12 emulators with Frida 17.x. - Use a **real Android device** for Frida captures, or use Android HCI snoop log only. + +> The confirmed BP1 Pro packet table lives in +> [`docs/protocol/bp1-pro-anc.md`](protocol/bp1-pro-anc.md) — treat that as the source of truth. +> Note the `02F0…` UUIDs that appear in APK static analysis are a **decoy**: they belong to a +> different Bluetrum device variant and are *not* what the BP1 Pro actually uses. + +- **Bluetooth profile**: The BP1 Pro ANC uses **BLE GATT** for runtime control (and accepts the + same command bytes over Classic SPP/RFCOMM — see issue #3). Filter Wireshark for `btatt`. +- **GATT service (confirmed)**: `53527aa4-29f7-ae11-4e74-997334782568` +- **Write characteristic**: `ee684b1a-1e9b-ed3e-ee55-f894667e92ac` (app → device commands) +- **Notify characteristic**: `654b749c-e37f-ae1f-ebab-40ca133e3690` (device → app events) +- **Frame shape**: `AA ` for notifications, `BA ` for writes — + no length field or CRC on the BLE side. +- **Battery**: opening the case lid triggers a notify; `AA 02 00 01`. +- **ANC**: toggling ANC writes `BA 34 `; the device acks with `AA 34 …`. +- **Frida note**: the Java bridge does not work on x86_64 Android 12 emulators with Frida 17.x. + Use a **real Android device** for Frida captures, or use the Android HCI snoop log only. ## Adding support for a new model -### Option A: Full RE from scratch (you own the device) -1. Run the capture procedure above with your device paired. -2. Create `docs/protocol/.md` with the packet table. -3. Create `crates/baseus-protocol/src/models/.rs` implementing `decode_frame`. -4. Add your model variant to `BaseusModel` in `crates/baseus-protocol/src/types.rs`. -5. Open a PR with captures committed to `docs/protocol/captures/`. - -### Option B: APK-extracted draft (you own the device but skip deep RE) -1. Run `python tools/extract_apk_model.py --name "Baseus "` and review the output. -2. Install the app and let it connect to your device. -3. In nRF Connect, find your device and list its GATT services. Identify the service, write, and - notify UUIDs. These override the APK candidates in `types.rs`. -4. Subscribe to the notify characteristic and interact with the device (open case, toggle ANC, - check battery). Capture the raw hex frames. -5. Update `docs/protocol/.md` and `crates/baseus-protocol/src/models/.rs` - with confirmed values. -6. Change `ModelStatus` from `Experimental` to `Verified` in `types.rs`. -7. Open a PR — the review checklist is in `docs/protocol/inspire-xh1.md` under "How to verify". - -## Confirming an experimental model (e.g. Inspire XH1) - -If the app ships an **experimental** model (yellow banner in the UI), here is how to promote it -to Verified: - -1. **Install the app** and let it attempt to connect to your device. -2. **Check the logs** (`RUST_LOG=debug` in the environment) for connection attempts — you'll see - which advertising name it scanned for and whether the GATT characteristics were found. -3. **Run nRF Connect** (free, Google Play) on an Android phone with your device paired. - List services → note which UUID family is actually present. Update `types.rs::gatt_uuids()`. -4. **Subscribe to the notify characteristic** in nRF Connect and interact with the device: - - Toggle ANC modes in the Baseus app → capture the `0xAA 0x??` notifications. - - Open/close case or check battery in the Baseus app → capture battery notifications. -5. **Update `docs/protocol/.md`** with confirmed byte values and remove ⚠ markers. -6. **Update `crates/baseus-protocol/src/models/.rs`**: - - Replace candidate opcodes with confirmed values. - - Remove `// APK-EXTRACTED, UNVERIFIED` comments from confirmed lines. - - Remove `#[ignore]` from hardware test placeholders and fill in real captures. -7. **Change `ModelStatus` to `Verified`** in `types.rs::status()`. -8. **Open a PR** — title: `feat: verify protocol`. Include a screenshot of the app - showing your device's battery/ANC with confirmed data. +**Verified-on-hardware only.** This project deliberately does not ship APK-guessed models +(the earlier Inspire XH1/XP1/XC1 drafts were removed for exactly this reason). If you own a +Baseus device that isn't the BP1 Pro: + +1. Run the capture procedure above with your device paired, and confirm every value against + real notifications — don't rely on APK candidates alone. +2. Create `docs/protocol/.md` with the packet table and commit sanitised captures + to `docs/protocol/captures/`. +3. Create `crates/baseus-protocol/src/models/.rs` implementing `decode_frame`, + with golden tests built from your real captures. +4. Register your model in `BaseusModel` (`crates/baseus-protocol/src/types.rs`) — advertising + name(s), `gatt_uuids()`, and command encoding in `execute_command`. +5. Open a PR titled `feat: add protocol`, with a screenshot of the app showing your + device's live battery/ANC. + +> **Coming soon:** an in-app **Capture Studio** (scan any device, live hex log, guided capture, +> export a shareable bundle) plus a **declarative model format** so adding a device becomes data +> + a golden test rather than a hand-written module. See `BACKLOG.md`. From 9783b759a1bf5a6cfc4c1c35d52de97c7bbd3803 Mon Sep 17 00:00:00 2001 From: elaxptr Date: Fri, 3 Jul 2026 19:45:12 +0200 Subject: [PATCH 3/4] feat(ui): UX glow-up motion pass Layer motion + delight on top of the existing dark/indigo identity (no visual redesign). A shared motion system lives in index.css (easing/duration tokens, keyframes, reduced-motion contract); components opt in via classes. - Game mode: animated pill switch with sliding knob + rotating accent halo and ambient glow when ON, replacing the plain ON/OFF text. - Battery rings sweep + count up from zero (reactive useCountUp tween) with a soft glow; charging bolts pulse; case bar fills on entrance. - Sidebar active indicator glides between icons on a spring curve. - Tab panels fade-and-lift in (conditional render re-fires entrance + ring count-up). - Hover-lift / press-scale / find-button ripple micro-interactions. - Numeric readouts (battery %, level, timer) use a monospace stack. - Header ambient glow breathes; connection dot pulses. All motion collapses under prefers-reduced-motion. Verified: tsc --noEmit and a vite production build both clean. --- apps/baseus-app/src/App.tsx | 95 +++++++------ apps/baseus-app/src/components/AncTab.tsx | 155 ++++++++++----------- apps/baseus-app/src/components/HomeTab.tsx | 78 +++++++++-- apps/baseus-app/src/components/Sidebar.tsx | 114 +++++++-------- apps/baseus-app/src/index.css | 117 +++++++++++++++- 5 files changed, 364 insertions(+), 195 deletions(-) diff --git a/apps/baseus-app/src/App.tsx b/apps/baseus-app/src/App.tsx index 3600d9f..2410432 100644 --- a/apps/baseus-app/src/App.tsx +++ b/apps/baseus-app/src/App.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'; import Sidebar, { type Tab } from './components/Sidebar'; import HomeTab from './components/HomeTab'; import AncTab from './components/AncTab'; @@ -124,8 +124,6 @@ export default function App() { const statusText = () => status() === 'connected' ? 'Connected' : status() === 'connecting' ? 'Connecting…' : 'Disconnected'; - const tab = (id: Tab) => ({ display: activeTab() === id ? 'block' : 'none' }); - return (
{/* Title bar */}
-
+
{statusText()}
@@ -178,46 +177,54 @@ export default function App() {
-
- r.pct)} - rightHistory={right().map((r) => r.pct)} - elapsed={useElapsed()()} - showTimer={getSettingsStore().show_session_timer} - /> -
- -
- -
- -
- -
- -
- setUpdateVersion(null)} /> -
+ +
+ r.pct)} + rightHistory={right().map((r) => r.pct)} + elapsed={useElapsed()()} + showTimer={getSettingsStore().show_session_timer} + /> +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ setUpdateVersion(null)} /> +
+
diff --git a/apps/baseus-app/src/components/AncTab.tsx b/apps/baseus-app/src/components/AncTab.tsx index dc113dd..f6233a5 100644 --- a/apps/baseus-app/src/components/AncTab.tsx +++ b/apps/baseus-app/src/components/AncTab.tsx @@ -19,10 +19,16 @@ const MODE_META: Record = transparency: { icon: '🌬️', name: 'Transparency', desc: 'Lets ambient sound in' }, }; -// Level slider only applies to BP1 ANC/Transparency modes. +// Level slider only applies to ANC/Transparency modes. const LEVEL_MODES: AncMode[] = ['anc', 'transparency']; +function prefersReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + export default function AncTab(props: Props) { + let gameRef: HTMLButtonElement | undefined; + function sliderInput(e: Event) { props.onLevel(Number((e.target as HTMLInputElement).value)); } @@ -30,6 +36,18 @@ export default function AncTab(props: Props) { props.onLevelCommit(Number((e.target as HTMLInputElement).value)); } + function handleGameClick() { + props.onGameMode(!props.gameMode); + if (gameRef && !prefersReducedMotion()) { + gameRef.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(0.98)' }, { transform: 'scale(1)' }], + { duration: 240, easing: 'ease-out' }, + ); + } + } + + const showStrength = () => LEVEL_MODES.includes(props.mode); + return (
Noise Control
@@ -41,6 +59,8 @@ export default function AncTab(props: Props) { const isLoading = () => props.loading === mode; return ( ); })}
- {/* Level slider — only shown for BP1 ANC/Transparency modes */} - {LEVEL_MODES.includes(props.mode) && ( - <> -
Strength
-
+
Strength
+
+
+ Level + + {props.level} / 10 + +
+ -
- Level - - {props.level} / 10 - -
- -
- LowHigh -
+ /> +
+ LowHigh
- - )} +
+
{/* Game / low-latency mode — independent toggle, not an ANC state */} {props.showGameMode && ( <>
Game Mode
- + +
)}
diff --git a/apps/baseus-app/src/components/HomeTab.tsx b/apps/baseus-app/src/components/HomeTab.tsx index f5401e6..c116e56 100644 --- a/apps/baseus-app/src/components/HomeTab.tsx +++ b/apps/baseus-app/src/components/HomeTab.tsx @@ -1,3 +1,4 @@ +import { createSignal, createEffect, onCleanup, untrack } from 'solid-js'; import SparkLine from './SparkLine'; import { findEarbud } from '../lib/tauri'; import { formatElapsed } from '../lib/timer'; @@ -20,6 +21,38 @@ interface Props { const CIRC = 2 * Math.PI * 28; +function reduceMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/// Smoothly tween a displayed value toward `target`, re-running whenever it +/// changes (so rings sweep + count up on mount and on each new battery reading). +function useCountUp(target: () => number): () => number { + const [shown, setShown] = createSignal(0); + let raf = 0; + createEffect(() => { + const to = target(); + if (reduceMotion()) { + setShown(to); + return; + } + cancelAnimationFrame(raf); + const from = untrack(shown); + let startTs = 0; + const dur = 900; + const tick = (now: number) => { + if (!startTs) startTs = now; + const t = Math.min((now - startTs) / dur, 1); + const eased = 1 - Math.pow(1 - t, 3); + setShown(Math.round(from + (to - from) * eased)); + if (t < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + }); + onCleanup(() => cancelAnimationFrame(raf)); + return shown; +} + function BudRing(p: { pct: number; charging: boolean; @@ -28,14 +61,16 @@ function BudRing(p: { label: string; history: number[]; }) { + const shown = useCountUp(() => p.pct); const isLow = () => p.pct > 0 && p.pct < 20; const color = () => (isLow() ? '#ef4444' : '#22c55e'); - const offset = () => CIRC * (1 - p.pct / 100); + const offset = () => CIRC * (1 - shown() / 100); const wearColor = () => !p.wearKnown ? '#2a2a2a' : p.inEar ? '#22c55e' : '#3f3f3f'; return (
- - {p.pct} + + {shown()} %
@@ -98,7 +133,7 @@ function BudRing(p: { {p.label} {p.charging && ( - + )}
@@ -106,6 +141,7 @@ function BudRing(p: { } export default function HomeTab(props: Props) { + const caseShown = useCountUp(() => props.casePct); const caseColor = () => props.casePct > 0 && props.casePct < 20 ? '#ef4444' : '#eab308'; @@ -132,6 +168,7 @@ export default function HomeTab(props: Props) {
Case
Case - - {props.casePct}% + + {caseShown()}%
{props.caseCharging && ( - + )}
@@ -182,6 +218,7 @@ export default function HomeTab(props: Props) {
Today
-
+
{formatElapsed(props.elapsed)}
Current session
@@ -211,9 +248,24 @@ function Divider() { } function FindBtn(p: { label: string; onClick: () => void }) { + function handleClick(e: MouseEvent) { + const btn = e.currentTarget as HTMLElement; + if (!reduceMotion()) { + const rect = btn.getBoundingClientRect(); + const r = document.createElement('span'); + r.className = 'ripple'; + r.style.left = `${e.clientX - rect.left}px`; + r.style.top = `${e.clientY - rect.top}px`; + btn.appendChild(r); + setTimeout(() => r.remove(), 600); + } + p.onClick(); + } + return ( ))} - {/* Spacer + settings at bottom */}
+