3 Commits

Author SHA1 Message Date
86875075fc cleanroom: modernize tui diagnostics and align release packaging 2026-03-07 13:30:46 -05:00
Brook
aaa321e9ff Delete CNAME 2026-03-05 20:40:29 -05:00
Brook
3e46a36693 Create CNAME 2026-03-05 20:33:37 -05:00
58 changed files with 6554 additions and 3758 deletions

View File

@@ -4,13 +4,13 @@ on:
workflow_call:
inputs:
tag:
description: "Release tag to publish (for example: v0.0.1-rc.1)"
description: "Release tag to publish (for example: v0.0.1-rc.2)"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish (for example: v0.0.1-rc.1)"
description: "Release tag to publish (for example: v0.0.1-rc.2)"
required: true
type: string
@@ -54,8 +54,6 @@ jobs:
--pattern "openbitdo-${TAG}-linux-aarch64.tar.gz" \
--pattern "openbitdo-${TAG}-macos-arm64.tar.gz" \
--dir /tmp/release-input
gh api "repos/${GITHUB_REPOSITORY}/tarball/${TAG}" \
> "/tmp/release-input/openbitdo-${TAG}-source.tar.gz"
bash packaging/scripts/render_release_metadata.sh \
"$TAG" \
"$GITHUB_REPOSITORY" \
@@ -64,8 +62,6 @@ jobs:
useradd -m builder
chown -R builder:builder /tmp/release-metadata
su builder -s /bin/bash -c "set -euo pipefail; \
cd /tmp/release-metadata/aur/openbitdo; \
makepkg --printsrcinfo > .SRCINFO; \
cd /tmp/release-metadata/aur/openbitdo-bin; \
makepkg --printsrcinfo > .SRCINFO"
@@ -74,8 +70,6 @@ jobs:
with:
name: aur-rendered-metadata-${{ inputs.tag }}
path: |
/tmp/release-metadata/aur/openbitdo/PKGBUILD
/tmp/release-metadata/aur/openbitdo/.SRCINFO
/tmp/release-metadata/aur/openbitdo-bin/PKGBUILD
/tmp/release-metadata/aur/openbitdo-bin/.SRCINFO
/tmp/release-metadata/checksums.env
@@ -96,7 +90,7 @@ jobs:
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
chmod 644 "$HOME/.ssh/known_hosts"
- name: Publish openbitdo and openbitdo-bin
- name: Publish openbitdo-bin
env:
GIT_SSH_COMMAND: ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=accept-new
run: |
@@ -141,5 +135,4 @@ jobs:
fi
}
publish_pkg openbitdo
publish_pkg openbitdo-bin

View File

@@ -37,14 +37,12 @@ jobs:
run: |
set -euo pipefail
if grep -nE 'SKIP|:no_check' \
packaging/aur/openbitdo/PKGBUILD \
packaging/aur/openbitdo-bin/PKGBUILD \
packaging/homebrew/Formula/openbitdo.rb; then
echo "Found placeholder checksum markers; release metadata must be pinned." >&2
exit 1
fi
test -f packaging/scripts/render_release_metadata.sh
test -f packaging/aur/openbitdo/PKGBUILD.tmpl
test -f packaging/aur/openbitdo-bin/PKGBUILD.tmpl
test -f packaging/homebrew/Formula/openbitdo.rb.tmpl
- name: Validate PKGBUILD and .SRCINFO
@@ -52,9 +50,6 @@ jobs:
useradd -m builder
chown -R builder:builder "$GITHUB_WORKSPACE"
su builder -s /bin/bash -c "set -euo pipefail; \
cd '$GITHUB_WORKSPACE/packaging/aur/openbitdo'; \
makepkg --printsrcinfo > /tmp/openbitdo.srcinfo; \
diff -u .SRCINFO /tmp/openbitdo.srcinfo; \
cd '$GITHUB_WORKSPACE/packaging/aur/openbitdo-bin'; \
makepkg --printsrcinfo > /tmp/openbitdo-bin.srcinfo; \
diff -u .SRCINFO /tmp/openbitdo-bin.srcinfo"

View File

@@ -297,8 +297,6 @@ jobs:
--pattern "openbitdo-${GITHUB_REF_NAME}-linux-aarch64.tar.gz" \
--pattern "openbitdo-${GITHUB_REF_NAME}-macos-arm64.tar.gz" \
--dir /tmp/release-input
gh api "repos/${GITHUB_REPOSITORY}/tarball/${GITHUB_REF_NAME}" \
> "/tmp/release-input/openbitdo-${GITHUB_REF_NAME}-source.tar.gz"
bash packaging/scripts/render_release_metadata.sh \
"${GITHUB_REF_NAME}" \
"$GITHUB_REPOSITORY" \

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## Unreleased
### Changed
- CLI contract reverted to single-command interactive launch:
- `openbitdo [--mock]`
- subcommand forms `openbitdo ui ...` and `openbitdo run ...` are rejected.
- Headless automation output remains available in the `bitdo_tui` Rust API (human and line-delimited JSON records).
- AUR packaging/publish flow now targets `openbitdo-bin` only.
- Settings schema is now documented as v2:
- `schema_version`
- `advanced_mode`
- `report_save_mode`
- `device_filter_text`
- `dashboard_layout_mode`
- `last_panel_focus`
## v0.0.1-rc.1
### Added

View File

@@ -1,54 +1,57 @@
# OpenBitdo Migration Notes
## Scope
This migration restores the single-command `openbitdo` CLI contract and removes the `ui`/`run` subcommand surface from user-facing usage.
## What changed
- `bitdoctl` was removed.
- `openbitdo cmd ...` was removed.
- JSON report/output flags were removed from user-facing flows.
- OpenBitdo now focuses on a single beginner entrypoint: `openbitdo`.
- `openbitdo` now launches interactive TUI directly (with optional `--mock`).
- subcommand forms `openbitdo ui ...` and `openbitdo run ...` are rejected (historical).
- headless output modes remain available through the Rust API, not the CLI.
- Settings schema moved to v2 fields while keeping compatibility defaults for v1 files.
## Command mapping
| Prior command form | Current command |
| --- | --- |
| `cargo run -p openbitdo --` | `cargo run -p openbitdo --` |
| `cargo run -p openbitdo -- --mock` | `cargo run -p openbitdo -- --mock` |
| `openbitdo ui --mock` (historical) | `openbitdo --mock` |
| `openbitdo run ...` (historical) | Not supported in CLI |
## New usage
From `/Users/brooklyn/data/8bitdo/cleanroom/sdk`:
Interactive dashboard:
```bash
cargo run -p openbitdo --
```
Optional mock mode:
```bash
cargo run -p openbitdo -- --mock
```
## Beginner flow
1. Launch `openbitdo`.
2. Select a detected device.
3. Click or choose an action:
- `Update` (guided firmware flow)
- `Diagnose` (quick readiness checks)
- `Refresh`
- `Quit`
4. Confirm with a simple `y`/`yes` prompt before firmware transfer.
## Historical note
The temporary subcommand surface (`openbitdo ui` / `openbitdo run`) is historical (historical) and should not be used for current workflows.
## Firmware behavior
- OpenBitdo first attempts a recommended firmware download.
- If download or verification fails, it immediately asks for a local firmware file (`.bin`/`.fw`).
- Detect-only devices remain blocked from firmware write operations with a clear reason.
## Headless library API
Headless automation remains available to Rust callers through `bitdo_tui`:
## New device-specific wizards
- JP108 (`0x5209`/`0x520a`):
- Dedicated button mapping for `A`, `B`, and `K1-K8`
- Auto-backup before write
- One-click restore if needed
- Guided button test text after apply
- Ultimate2 (`0x6012`/`0x6013`):
- Slot + mode + core map editing
- Auto-backup and rollback path
- Guided button test text after apply
```bash
run_headless(core, RunLaunchOptions { output_mode: HeadlessOutputMode::Json, ..Default::default() })
```
## CI changes
- Hardware CI split into per-family jobs:
- `hardware-dinput` (required)
- `hardware-standard64` (required)
- `hardware-ultimate2` (required)
- `hardware-108jp` (required)
- `hardware-jphandshake` (gated until fixture availability)
## Settings schema migration
Current schema is `schema_version = 2` with fields:
- `advanced_mode`
- `report_save_mode`
- `device_filter_text`
- `dashboard_layout_mode`
- `last_panel_focus`
Compatibility behavior:
- v1 settings files load with defaults for missing v2 fields.
- if `advanced_mode = false`, `report_save_mode = off` is normalized to `failure_only`.
## CI note
The CLI smoke coverage now validates:
- `openbitdo --help` exposes single-command option usage.
- `openbitdo ui ...` and `openbitdo run ...` fail as unsupported forms (historical).

View File

@@ -1,9 +1,9 @@
# OpenBitdo RC Checklist (`v0.0.1-rc.1`)
# OpenBitdo RC Checklist (`v0.0.1-rc.2`)
This checklist defines release-candidate readiness for the first public RC tag.
This checklist defines release-candidate readiness for the `v0.0.1-rc.2` public RC tag.
## Candidate Policy
- Tag format: `v*` (for this RC: `v0.0.1-rc.1`)
- Tag format: `v*` (for this RC: `v0.0.1-rc.2`)
- Tag source: `main` only
- Release trigger: tag push
- RC gate: all required CI checks + manual smoke validation
@@ -22,9 +22,11 @@ Daily review cadence:
- run once per day until RC tag:
- `gh issue list -R bybrooklyn/openbitdo --label release-blocker --state open --limit 200`
- release remains blocked while this list is non-empty.
- AUR auth troubleshooting runbook:
- [aur_publish_troubleshooting.md](/Users/brooklyn/data/8bitdo/cleanroom/process/aur_publish_troubleshooting.md)
## Scope-Completeness Gate ("Good Point")
Before tagging `v0.0.1-rc.1`, RC scope must match the locked contract:
Before tagging `v0.0.1-rc.2`, RC scope must match the locked contract:
- JP108 mapping supports dedicated keys only (`A/B/K1-K8`) for RC.
- Ultimate2 expanded mapping supports RC-required fields only:
- remappable slots `A/B/K1-K8`
@@ -71,42 +73,45 @@ Tag preflight must fail early if any required secret is missing:
## Artifact Expectations
Release assets must include:
- `openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz`
- `openbitdo-v0.0.1-rc.1-linux-x86_64`
- `openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz`
- `openbitdo-v0.0.1-rc.1-linux-aarch64`
- `openbitdo-v0.0.1-rc.1-macos-arm64.tar.gz`
- `openbitdo-v0.0.1-rc.1-macos-arm64`
- `openbitdo-v0.0.1-rc.1-macos-arm64.pkg`
- `openbitdo-v0.0.1-rc.2-linux-x86_64.tar.gz`
- `openbitdo-v0.0.1-rc.2-linux-x86_64`
- `openbitdo-v0.0.1-rc.2-linux-aarch64.tar.gz`
- `openbitdo-v0.0.1-rc.2-linux-aarch64`
- `openbitdo-v0.0.1-rc.2-macos-arm64.tar.gz`
- `openbitdo-v0.0.1-rc.2-macos-arm64`
- `openbitdo-v0.0.1-rc.2-macos-arm64.pkg`
- corresponding `.sha256` files for every artifact above
## Verify Checksums
Run from release asset directory:
```bash
shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-x86_64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-aarch64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.pkg.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-x86_64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-x86_64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-aarch64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-aarch64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.tar.gz.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.sha256
shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.pkg.sha256
```
## Manual Smoke Matrix
1. Linux `x86_64`
- Extract tarball, run `./bin/openbitdo --mock`
- Confirm waiting/home flow renders
- Confirm About page opens (`a` and mouse click)
- Confirm dashboard renders (device list, quick actions, event panel)
- Confirm settings screen opens and returns to dashboard
2. Linux `aarch64`
- Extract tarball, run `./bin/openbitdo --mock`
- Confirm main navigation and update preflight render
- Confirm dashboard navigation and preflight/task render
3. macOS arm64
- Run standalone binary `openbitdo --mock`
- Install `.pkg`, then run `/opt/homebrew/bin/openbitdo --mock`
- Confirm launch and About page behavior
- Confirm launch and settings view behavior
## Help Surface Verification
- `openbitdo --help` shows single-command usage with `--mock`.
## Distribution Readiness Notes
- Homebrew publication runs after release asset publish when `HOMEBREW_PUBLISH_ENABLED=1`.
@@ -123,6 +128,8 @@ shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.pkg.sha256
| RC release allowed | Fail | `No` yet: AUR SSH auth still returns `Permission denied (publickey)`. |
## RC Execution Log
Historical note: entries below may mention older command forms from prior milestones and are preserved as-is for audit history.
- 2026-03-02T20:54:31Z: governance preflight complete; release blocker remains open by policy.
- 2026-03-02T21:38:17Z: set `HOMEBREW_TAP_REPO=bybrooklyn/homebrew-openbitdo`; repository and tap visibility switched to public.
- 2026-03-02T21:40:00Z: bootstrapped tap repo `bybrooklyn/homebrew-openbitdo` with initial `Formula/openbitdo.rb`.

View File

@@ -16,7 +16,7 @@ Optional mock mode:
cargo run -p openbitdo -- --mock
```
Beginner flow is always `openbitdo` only. No extra command surface is required.
`openbitdo` launches the interactive dashboard directly.
## Install (Public RC)
@@ -30,11 +30,10 @@ brew install openbitdo
Homebrew Core inclusion (`brew install openbitdo` without tapping) is not an RC blocker and may land later.
AUR package names:
- `openbitdo` (source build)
- `openbitdo-bin` (prebuilt binary)
macOS RC caveat:
- `.pkg` installers are unsigned/ad-hoc for `v0.0.1-rc.1`.
- `.pkg` installers are unsigned/ad-hoc for `v0.0.1-rc.2`.
- notarization is required starting with `v0.1.0`.
## UI Language Support
@@ -182,5 +181,5 @@ Support is implemented to our best current knowledge. Coverage and confidence ar
- Dirty-room source index + official web cross-check URLs: [device_name_sources.md](/Users/brooklyn/data/8bitdo/cleanroom/process/device_name_sources.md)
## Public RC Gate
`v0.0.1-rc.1` remains blocked until release-blocker issues are zero and all required checks are green.
The next RC remains blocked until release-blocker issues are zero and all required checks are green.
RC gating is checklist-based (see [RC_CHECKLIST.md](/Users/brooklyn/data/8bitdo/cleanroom/RC_CHECKLIST.md)).

View File

@@ -1,7 +1,6 @@
# AUR Packaging
This directory contains AUR package sources for:
- `openbitdo` (source build)
- `openbitdo-bin` (prebuilt release assets)
Publishing is automated by `.github/workflows/aur-publish.yml` and remains gated:
@@ -12,8 +11,7 @@ Publish flow:
1. wait for release assets from a `v*` tag
2. compute authoritative SHA256 values from released artifacts
3. render `PKGBUILD`/`.SRCINFO` with pinned hashes
4. push updates to AUR repos
4. push updates to AUR repo
Template files used for release rendering:
- `openbitdo/PKGBUILD.tmpl`
- `openbitdo-bin/PKGBUILD.tmpl`

View File

@@ -1,14 +0,0 @@
pkgbase = openbitdo
pkgdesc = Beginner-first clean-room 8BitDo utility
pkgver = 0.0.1rc1
pkgrel = 1
url = https://github.com/bybrooklyn/openbitdo
arch = x86_64
arch = aarch64
license = BSD-3-Clause
makedepends = cargo
depends = hidapi
source = openbitdo-v0.0.1-rc.1.tar.gz::https://github.com/bybrooklyn/openbitdo/archive/refs/tags/v0.0.1-rc.1.tar.gz
sha256sums = 0000000000000000000000000000000000000000000000000000000000000000
pkgname = openbitdo

View File

@@ -1,24 +0,0 @@
pkgname=openbitdo
pkgver=0.0.1rc1
_upstream_tag=v0.0.1-rc.1
pkgrel=1
pkgdesc="Beginner-first clean-room 8BitDo utility"
arch=('x86_64' 'aarch64')
url="https://github.com/bybrooklyn/openbitdo"
license=('BSD-3-Clause')
depends=('hidapi')
makedepends=('cargo')
source=("${pkgname}-${_upstream_tag}.tar.gz::${url}/archive/refs/tags/${_upstream_tag}.tar.gz")
sha256sums=('0000000000000000000000000000000000000000000000000000000000000000')
build() {
cd "${srcdir}/openbitdo-${_upstream_tag#v}/sdk"
cargo build --release -p openbitdo
}
package() {
cd "${srcdir}/openbitdo-${_upstream_tag#v}"
install -Dm755 "sdk/target/release/openbitdo" "${pkgdir}/usr/bin/openbitdo"
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/openbitdo/README.md"
install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/openbitdo/LICENSE"
}

View File

@@ -1,24 +0,0 @@
pkgname=openbitdo
pkgver=@AUR_PKGVER@
_upstream_tag=@UPSTREAM_TAG@
pkgrel=1
pkgdesc="Beginner-first clean-room 8BitDo utility"
arch=('x86_64' 'aarch64')
url="https://github.com/@REPOSITORY@"
license=('BSD-3-Clause')
depends=('hidapi')
makedepends=('cargo')
source=("${pkgname}-${_upstream_tag}.tar.gz::${url}/archive/refs/tags/${_upstream_tag}.tar.gz")
sha256sums=('@SOURCE_SHA256@')
build() {
cd "${srcdir}/openbitdo-${_upstream_tag#v}/sdk"
cargo build --release -p openbitdo
}
package() {
cd "${srcdir}/openbitdo-${_upstream_tag#v}"
install -Dm755 "sdk/target/release/openbitdo" "${pkgdir}/usr/bin/openbitdo"
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/openbitdo/README.md"
install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/openbitdo/LICENSE"
}

View File

@@ -7,7 +7,6 @@ Usage:
render_release_metadata.sh <tag> <repository> <input_dir> <output_dir>
Inputs expected in <input_dir>:
openbitdo-<tag>-source.tar.gz
openbitdo-<tag>-linux-x86_64.tar.gz
openbitdo-<tag>-linux-aarch64.tar.gz
openbitdo-<tag>-macos-arm64.tar.gz
@@ -46,13 +45,11 @@ aur_pkgver_from_tag() {
VERSION="${TAG#v}"
AUR_PKGVER="$(aur_pkgver_from_tag "$TAG")"
SOURCE_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-source.tar.gz"
LINUX_X86_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-x86_64.tar.gz"
LINUX_AARCH64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-aarch64.tar.gz"
MACOS_ARM64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-macos-arm64.tar.gz"
for required in \
"$SOURCE_ARCHIVE" \
"$LINUX_X86_ARCHIVE" \
"$LINUX_AARCH64_ARCHIVE" \
"$MACOS_ARM64_ARCHIVE"; do
@@ -62,13 +59,11 @@ for required in \
fi
done
SOURCE_SHA256="$(sha256 "$SOURCE_ARCHIVE")"
LINUX_X86_SHA256="$(sha256 "$LINUX_X86_ARCHIVE")"
LINUX_AARCH64_SHA256="$(sha256 "$LINUX_AARCH64_ARCHIVE")"
MACOS_ARM64_SHA256="$(sha256 "$MACOS_ARM64_ARCHIVE")"
mkdir -p \
"${OUTPUT_DIR}/aur/openbitdo" \
"${OUTPUT_DIR}/aur/openbitdo-bin" \
"${OUTPUT_DIR}/homebrew/Formula"
@@ -80,16 +75,12 @@ render() {
-e "s|@UPSTREAM_TAG@|${TAG}|g" \
-e "s|@VERSION@|${VERSION}|g" \
-e "s|@REPOSITORY@|${REPOSITORY}|g" \
-e "s|@SOURCE_SHA256@|${SOURCE_SHA256}|g" \
-e "s|@LINUX_X86_64_SHA256@|${LINUX_X86_SHA256}|g" \
-e "s|@LINUX_AARCH64_SHA256@|${LINUX_AARCH64_SHA256}|g" \
-e "s|@MACOS_ARM64_SHA256@|${MACOS_ARM64_SHA256}|g" \
"$template" > "$destination"
}
render \
"${ROOT}/packaging/aur/openbitdo/PKGBUILD.tmpl" \
"${OUTPUT_DIR}/aur/openbitdo/PKGBUILD"
render \
"${ROOT}/packaging/aur/openbitdo-bin/PKGBUILD.tmpl" \
"${OUTPUT_DIR}/aur/openbitdo-bin/PKGBUILD"
@@ -102,7 +93,6 @@ TAG=${TAG}
VERSION=${VERSION}
AUR_PKGVER=${AUR_PKGVER}
REPOSITORY=${REPOSITORY}
SOURCE_SHA256=${SOURCE_SHA256}
LINUX_X86_64_SHA256=${LINUX_X86_SHA256}
LINUX_AARCH64_SHA256=${LINUX_AARCH64_SHA256}
MACOS_ARM64_SHA256=${MACOS_ARM64_SHA256}

View File

@@ -0,0 +1,70 @@
# AUR Publish SSH Troubleshooting
This runbook focuses on resolving AUR publish failures such as `Permission denied (publickey)` in release workflows.
## Preconditions
- `AUR_USERNAME` secret exists.
- `AUR_SSH_PRIVATE_KEY` secret exists and contains the full private key block.
- Runner can reach `aur.archlinux.org:22`.
## 1) Key format and permissions checks
Run on a secure local shell before updating secrets:
```bash
mkdir -p /tmp/aur-debug && cd /tmp/aur-debug
cat > aur_key <<'KEY'
<PASTE_PRIVATE_KEY>
KEY
chmod 600 aur_key
ssh-keygen -y -f aur_key >/tmp/aur_key.pub
```
Expected:
- `ssh-keygen -y` succeeds.
- no passphrase prompt for CI use.
## 2) Known hosts and host verification
```bash
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
```
Expected:
- `aur.archlinux.org` host key is present in `known_hosts`.
## 3) SSH dry-run authentication
```bash
ssh -i /tmp/aur-debug/aur_key \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
${AUR_USERNAME}@aur.archlinux.org
```
Expected success signature:
- authentication accepted (AUR may close session after auth; that still proves key acceptance).
Expected failure signatures:
- `Permission denied (publickey)` means wrong key/user pairing.
- `Host key verification failed` means known_hosts mismatch/missing.
## 4) Repo-level publish dry run
For package repo:
```bash
git ls-remote ssh://${AUR_USERNAME}@aur.archlinux.org/openbitdo-bin.git
```
Expected:
- command returns refs without auth failures.
## 5) CI secret update checklist
- Store private key in `AUR_SSH_PRIVATE_KEY` exactly as multiline PEM/OpenSSH block.
- Store account name in `AUR_USERNAME`.
- Re-run release workflow preflight job.
## 6) Post-fix validation
- Confirm release preflight no longer fails on SSH auth.
- Confirm `publish-aur` job pushes `openbitdo-bin` metadata repo.

69
sdk/Cargo.lock generated
View File

@@ -161,10 +161,14 @@ dependencies = [
"bitdo_proto",
"chrono",
"crossterm 0.29.0",
"fuzzy-matcher",
"insta",
"ratatui",
"serde",
"serde_json",
"tokio",
"toml",
"unicode-width 0.2.0",
]
[[package]]
@@ -316,6 +320,18 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -558,6 +574,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -574,6 +596,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -643,6 +671,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -994,6 +1031,18 @@ dependencies = [
"rustversion",
]
[[package]]
name = "insta"
version = "1.46.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "instability"
version = "0.3.11"
@@ -1196,6 +1245,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"bitdo_app_core",
"bitdo_proto",
"bitdo_tui",
"clap",
"predicates",
@@ -1785,6 +1835,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1894,6 +1950,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"

View File

@@ -9,7 +9,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "0.1.0"
version = "0.0.1-rc.2"
license = "BSD-3-Clause"
[workspace.dependencies]
@@ -31,3 +31,6 @@ ratatui = "0.29"
crossterm = "0.29"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
fuzzy-matcher = "0.3"
unicode-width = "0.2"
insta = "1.43"

View File

@@ -31,50 +31,54 @@ cargo test --workspace --all-targets
cargo run -p openbitdo -- --mock
```
## Beginner-first behavior
- launch with no subcommands
- if no device is connected, OpenBitdo starts in a waiting screen with `Refresh`, `Help`, and `Quit`
- if one device is connected, it is auto-selected and ready for action
- choose `Recommended Update` or `Diagnose` from large clickable actions
- for JP108 devices (`0x5209`/`0x520a`), `Recommended Update` enters a dedicated-button wizard:
- edit `A/B/K1-K8`
- backup + apply
- guided button test
- for Ultimate2 devices (`0x6012`/`0x6013`), `Recommended Update` enters a core-profile wizard:
- choose slot (`Slot1/2/3`)
- set mode
- edit RC mapping slots (`A/B/K1-K8`) with known controller-button targets
- view/edit L2/R2 analog values when capability supports writes
- backup + apply
- guided button test
- firmware path defaults to verified recommended download; local file fallback is prompted if unavailable
- update transfer requires one plain-language `I Understand` confirmation
- detect-only PIDs stay read/diagnostic-only with a clear block reason
- mouse support:
- left click for primary actions
- right click on device rows for context menu actions
- scroll wheel to navigate device list/detail panes
- support reports are TOML only
- beginner mode: `failure_only` (default) or `always`
- advanced mode: `failure_only`, `always`, or `off` (with warning)
- advanced mode is toggled from About (`t` or click) and persisted to OS config TOML
- advanced report hotkeys after a failure report exists:
- `c` copy report path
- `o` open report file
- `f` open report folder
- open About from home (`a` key or click `About`) to view:
- app version
- git commit short and full hash
- build date (UTC)
- compile target triple
- runtime OS/arch
- firmware signing-key fingerprint (short with full-view toggle, plus next-key short)
## CLI surface
- `openbitdo [--mock]`: interactive dashboard flow (mouse-primary, minimal keyboard).
## Interactive behavior (`openbitdo`)
- dashboard starts with:
- searchable device list (left)
- quick actions (center)
- persistent event panel (right)
- primary quick actions:
- `Refresh`
- `Diagnose`
- `Recommended Update`
- `Edit Mapping` (capability-gated)
- `Settings`
- `Quit`
- firmware transfer path:
- preflight generation
- explicit confirm/cancel action
- updating progress and final result screen
- mapping editors are draft-first with:
- apply
- undo
- reset
- restore backup
- recovery lock behavior is preserved when rollback fails.
## Headless library API
- headless automation remains available as a Rust API in `bitdo_tui`:
- `run_headless`
- `RunLaunchOptions`
- `HeadlessOutputMode`
- `openbitdo` CLI does not expose a headless command surface.
## Config schema (v2)
- persisted fields:
- `schema_version`
- `advanced_mode`
- `report_save_mode`
- `device_filter_text`
- `dashboard_layout_mode`
- `last_panel_focus`
- v1 files are read with compatibility defaults and normalized to v2 fields at load time.
## Packaging
```bash
./scripts/package-linux.sh v0.0.1-rc.1 x86_64
./scripts/package-linux.sh v0.0.1-rc.1 aarch64
./scripts/package-macos.sh v0.0.1-rc.1 arm64 aarch64-apple-darwin
./scripts/package-linux.sh v0.0.1-rc.2 x86_64
./scripts/package-linux.sh v0.0.1-rc.2 aarch64
./scripts/package-macos.sh v0.0.1-rc.2 arm64 aarch64-apple-darwin
```
Packaging outputs use:
@@ -87,7 +91,7 @@ Packaging outputs use:
- CI checks remain in `.github/workflows/ci.yml`.
- Tag-based release workflow is in `.github/workflows/release.yml`.
- Release tags must originate from `main`.
- `v0.0.1-rc.1` style tags publish GitHub pre-releases.
- `v0.0.1-rc.2` style tags publish GitHub pre-releases.
- Release notes are sourced from `/Users/brooklyn/data/8bitdo/cleanroom/CHANGELOG.md`.
- Package-manager publish runs only after release assets are published.
@@ -102,15 +106,13 @@ Packaging outputs use:
- Homebrew install path for public RC:
- `brew tap bybrooklyn/openbitdo`
- `brew install openbitdo`
- Homebrew Core inclusion is not required for `v0.0.1-rc.1`.
- Homebrew Core inclusion is not required for `v0.0.1-rc.2`.
- Homebrew formula scaffold: `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/Formula/openbitdo.rb`
- Homebrew tap sync script (disabled by default): `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/sync_tap.sh`
- Tap repository: `bybrooklyn/homebrew-openbitdo`
- AUR package sources:
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo`
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo-bin`
- AUR package names:
- `openbitdo`
- `openbitdo-bin`
- Release metadata renderer:
- `/Users/brooklyn/data/8bitdo/cleanroom/packaging/scripts/render_release_metadata.sh`
@@ -121,7 +123,7 @@ Packaging outputs use:
- `release.yml` renders checksum-pinned formula and runs `sync_tap.sh`
- gated by `HOMEBREW_PUBLISH_ENABLED=1`
- macOS `.pkg` caveat:
- unsigned/ad-hoc is accepted for RC
- unsigned/ad-hoc is accepted for `v0.0.1-rc.2`
- notarization required for `v0.1.0`
## CI Gates

View File

@@ -1,14 +1,14 @@
use base64::Engine;
use bitdo_proto::{
device_profile_for, enumerate_hid_devices, BitdoErrorCode, DeviceSession, DiagProbeResult,
DiagSeverity, HidTransport, PidCapability, ProtocolFamily, SessionConfig, SupportEvidence,
SupportLevel, SupportTier, VidPid,
device_profile_for, enumerate_hid_devices, find_command, BitdoErrorCode, CommandId,
DeviceSession, DiagProbeResult, DiagSeverity, HidTransport, PidCapability, ProtocolFamily,
ResponseStatus, SessionConfig, SupportEvidence, SupportLevel, SupportTier, VidPid,
};
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -348,6 +348,11 @@ impl OpenBitdoCore {
pub fn beginner_diag_summary(&self, device: &AppDevice, diag: &DiagProbeResult) -> String {
let passed = diag.command_checks.iter().filter(|c| c.ok).count();
let total = diag.command_checks.len();
let issue_total = diag
.command_checks
.iter()
.filter(|c| !c.ok || c.severity != DiagSeverity::Ok)
.count();
let experimental_total = diag
.command_checks
.iter()
@@ -381,23 +386,28 @@ impl OpenBitdoCore {
}
};
let status_hint = if needs_attention > 0 {
format!("Needs attention: {needs_attention} safety-critical diagnostic signal(s).")
let status_hint = if issue_total > 0 {
format!("Issues: {issue_total} total, {needs_attention} need attention.")
} else {
"Needs attention: none.".to_owned()
"Issues: none.".to_owned()
};
let experimental_hint =
format!("Experimental checks: {experimental_ok}/{experimental_total} passed.");
let transport_hint = if diag.transport_ready {
"Transport ready: yes."
} else {
"Transport ready: no successful safe-read responses yet."
};
match device.support_tier {
SupportTier::Full => format!(
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is full-support."
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is full-support."
),
SupportTier::CandidateReadOnly => format!(
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is candidate-readonly: update and mapping stay blocked until runtime + hardware confirmation."
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is candidate-readonly: update and mapping stay blocked until runtime + hardware confirmation."
),
SupportTier::DetectOnly => format!(
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is detect-only: use diagnostics only."
"{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is detect-only: use diagnostics only."
),
}
}
@@ -1790,6 +1800,30 @@ fn mock_device(vid_pid: VidPid, full: bool) -> AppDevice {
fn mock_diag_probe(target: VidPid) -> DiagProbeResult {
let profile = device_profile_for(target);
let command_checks = mock_diag_commands_for(&profile, target)
.into_iter()
.map(|command| {
let parsed_facts = mock_diag_parsed_facts(command, target);
let detail = mock_diag_detail(command, &parsed_facts);
let row = find_command(command).expect("mock diag command exists");
bitdo_proto::DiagCommandStatus {
command,
ok: true,
confidence: row.evidence_confidence(),
is_experimental: row.experimental_default,
severity: bitdo_proto::DiagSeverity::Ok,
attempts: 1,
validator: format!("mock:{command:?}"),
response_status: ResponseStatus::Ok,
bytes_written: row.request.len(),
bytes_read: 64,
error_code: None,
detail,
parsed_facts,
}
})
.collect::<Vec<_>>();
DiagProbeResult {
target,
profile_name: profile.name,
@@ -1799,35 +1833,203 @@ fn mock_diag_probe(target: VidPid) -> DiagProbeResult {
capability: profile.capability,
evidence: profile.evidence,
transport_ready: true,
command_checks: vec![
bitdo_proto::DiagCommandStatus {
command: bitdo_proto::CommandId::GetPid,
ok: true,
confidence: bitdo_proto::EvidenceConfidence::Confirmed,
is_experimental: false,
severity: bitdo_proto::DiagSeverity::Ok,
error_code: None,
detail: "ok".to_owned(),
},
bitdo_proto::DiagCommandStatus {
command: bitdo_proto::CommandId::GetControllerVersion,
ok: true,
confidence: bitdo_proto::EvidenceConfidence::Confirmed,
is_experimental: false,
severity: bitdo_proto::DiagSeverity::Ok,
error_code: None,
detail: "ok".to_owned(),
},
bitdo_proto::DiagCommandStatus {
command: bitdo_proto::CommandId::GetSuperButton,
ok: true,
confidence: bitdo_proto::EvidenceConfidence::Inferred,
is_experimental: true,
severity: bitdo_proto::DiagSeverity::Ok,
error_code: None,
detail: "ok".to_owned(),
},
],
command_checks,
}
}
fn mock_diag_commands_for(profile: &bitdo_proto::DeviceProfile, target: VidPid) -> Vec<CommandId> {
const SAFE_READ_ORDER: &[CommandId] = &[
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetMode,
CommandId::GetModeAlt,
CommandId::GetControllerVersion,
CommandId::GetSuperButton,
CommandId::Idle,
CommandId::Version,
CommandId::ReadProfile,
CommandId::Jp108ReadDedicatedMappings,
CommandId::Jp108ReadFeatureFlags,
CommandId::Jp108ReadVoice,
CommandId::U2GetCurrentSlot,
CommandId::U2ReadConfigSlot,
CommandId::U2ReadButtonMap,
];
SAFE_READ_ORDER
.iter()
.copied()
.filter(|command| mock_diag_command_allowed(profile, target, *command))
.collect()
}
fn mock_diag_command_allowed(
profile: &bitdo_proto::DeviceProfile,
target: VidPid,
command: CommandId,
) -> bool {
let Some(row) = find_command(command) else {
return false;
};
if !row.applies_to.is_empty() && !row.applies_to.contains(&target.pid) {
return false;
}
if !mock_diag_family_allowed(profile.protocol_family, command) {
return false;
}
if !mock_diag_capability_allowed(profile.capability, command) {
return false;
}
if profile.support_tier == SupportTier::CandidateReadOnly
&& !mock_diag_candidate_allowed(target.pid, command)
{
return false;
}
true
}
fn mock_diag_family_allowed(family: ProtocolFamily, command: CommandId) -> bool {
match family {
ProtocolFamily::Unknown => matches!(
command,
CommandId::GetPid
| CommandId::GetReportRevision
| CommandId::GetControllerVersion
| CommandId::Version
| CommandId::Idle
),
ProtocolFamily::JpHandshake => !matches!(
command,
CommandId::GetMode
| CommandId::GetModeAlt
| CommandId::ReadProfile
| CommandId::U2GetCurrentSlot
| CommandId::U2ReadConfigSlot
| CommandId::U2ReadButtonMap
),
ProtocolFamily::DS4Boot => matches!(
command,
CommandId::GetPid
| CommandId::GetReportRevision
| CommandId::GetControllerVersion
| CommandId::Version
| CommandId::Idle
),
ProtocolFamily::Standard64 | ProtocolFamily::DInput => !matches!(
command,
CommandId::Jp108ReadDedicatedMappings
| CommandId::Jp108ReadFeatureFlags
| CommandId::Jp108ReadVoice
),
}
}
fn mock_diag_capability_allowed(capability: PidCapability, command: CommandId) -> bool {
match command {
CommandId::GetPid
| CommandId::GetReportRevision
| CommandId::GetControllerVersion
| CommandId::GetSuperButton
| CommandId::Idle
| CommandId::Version => true,
CommandId::GetMode | CommandId::GetModeAlt => capability.supports_mode,
CommandId::ReadProfile => capability.supports_profile_rw,
CommandId::Jp108ReadDedicatedMappings
| CommandId::Jp108ReadFeatureFlags
| CommandId::Jp108ReadVoice => capability.supports_jp108_dedicated_map,
CommandId::U2GetCurrentSlot | CommandId::U2ReadConfigSlot => {
capability.supports_u2_slot_config
}
CommandId::U2ReadButtonMap => capability.supports_u2_button_map,
_ => false,
}
}
fn mock_diag_candidate_allowed(pid: u16, command: CommandId) -> bool {
const BASE_DIAG_READS: &[CommandId] = &[
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetControllerVersion,
CommandId::Version,
CommandId::Idle,
];
const STANDARD_CANDIDATE_PIDS: &[u16] = &[
0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x3004, 0x3019, 0x3100, 0x3105, 0x2100,
0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028, 0x3026, 0x3027,
];
const JP_CANDIDATE_PIDS: &[u16] = &[0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e];
if BASE_DIAG_READS.contains(&command) {
return STANDARD_CANDIDATE_PIDS.contains(&pid) || JP_CANDIDATE_PIDS.contains(&pid);
}
if STANDARD_CANDIDATE_PIDS.contains(&pid) {
return matches!(
command,
CommandId::GetMode | CommandId::GetModeAlt | CommandId::ReadProfile
);
}
false
}
fn mock_diag_parsed_facts(command: CommandId, target: VidPid) -> BTreeMap<String, u32> {
let mut facts = BTreeMap::new();
match command {
CommandId::GetPid => {
facts.insert("detected_pid".to_owned(), target.pid as u32);
}
CommandId::GetReportRevision => {
facts.insert("revision".to_owned(), 1);
}
CommandId::GetMode | CommandId::GetModeAlt => {
facts.insert("mode".to_owned(), 2);
}
CommandId::GetControllerVersion | CommandId::Version => {
facts.insert("version_x100".to_owned(), 4200);
facts.insert("beta".to_owned(), 0);
}
CommandId::U2GetCurrentSlot => {
facts.insert("slot".to_owned(), 1);
}
_ => {}
}
facts
}
fn mock_diag_detail(command: CommandId, parsed_facts: &BTreeMap<String, u32>) -> String {
match command {
CommandId::GetPid => parsed_facts
.get("detected_pid")
.map(|pid| format!("detected pid {pid:#06x}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetReportRevision => parsed_facts
.get("revision")
.map(|revision| format!("report revision {revision}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetMode | CommandId::GetModeAlt => parsed_facts
.get("mode")
.map(|mode| format!("mode {mode}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetControllerVersion | CommandId::Version => {
let version_x100 = parsed_facts.get("version_x100").copied();
let beta = parsed_facts.get("beta").copied().unwrap_or(0);
version_x100
.map(|version| {
format!(
"firmware {}.{:02} beta={beta}",
version / 100,
version % 100
)
})
.unwrap_or_else(|| "ok".to_owned())
}
CommandId::U2GetCurrentSlot => parsed_facts
.get("slot")
.map(|slot| format!("current slot {slot}"))
.unwrap_or_else(|| "ok".to_owned()),
_ => "ok".to_owned(),
}
}

View File

@@ -88,8 +88,14 @@ pub struct DiagCommandStatus {
pub confidence: EvidenceConfidence,
pub is_experimental: bool,
pub severity: DiagSeverity,
pub attempts: u8,
pub validator: String,
pub response_status: ResponseStatus,
pub bytes_written: usize,
pub bytes_read: usize,
pub error_code: Option<BitdoErrorCode>,
pub detail: String,
pub parsed_facts: BTreeMap<String, u32>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
@@ -210,60 +216,12 @@ impl<T: Transport> DeviceSession<T> {
}
pub fn diag_probe(&mut self) -> DiagProbeResult {
let target_pid = self.target.pid;
let checks_to_run = [
CommandId::GetPid,
CommandId::GetReportRevision,
CommandId::GetMode,
CommandId::GetControllerVersion,
// Inferred safe reads are intentionally included in diagnostics so
// users always see signal quality, but results are labeled
// experimental and only strict safety conditions escalate.
CommandId::GetSuperButton,
CommandId::ReadProfile,
]
.iter()
.filter_map(|cmd| {
let row = find_command(*cmd)?;
if row.safety_class != SafetyClass::SafeRead {
return None;
}
if !command_applies_to_pid(row, target_pid) {
return None;
}
Some((*cmd, row.runtime_policy(), row.evidence_confidence()))
})
.collect::<Vec<_>>();
let checks_to_run = self.diag_commands_to_run();
let mut checks = Vec::with_capacity(checks_to_run.len());
for (cmd, runtime_policy, confidence) in checks_to_run {
match self.send_command(cmd, None) {
Ok(_) => checks.push(DiagCommandStatus {
command: cmd,
ok: true,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: DiagSeverity::Ok,
error_code: None,
detail: "ok".to_owned(),
}),
Err(err) => checks.push(DiagCommandStatus {
command: cmd,
ok: false,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: classify_diag_failure(
cmd,
runtime_policy,
confidence,
err.code(),
self.target.pid,
),
error_code: Some(err.code()),
detail: err.to_string(),
}),
}
for (command, runtime_policy, confidence) in checks_to_run {
checks.push(self.run_diag_check(command, runtime_policy, confidence));
}
let transport_ready = checks.iter().any(|check| check.ok);
DiagProbeResult {
target: self.target,
@@ -273,11 +231,193 @@ impl<T: Transport> DeviceSession<T> {
protocol_family: self.profile.protocol_family,
capability: self.profile.capability,
evidence: self.profile.evidence,
transport_ready: true,
transport_ready,
command_checks: checks,
}
}
fn diag_commands_to_run(&self) -> Vec<(CommandId, CommandRuntimePolicy, EvidenceConfidence)> {
CommandId::all()
.iter()
.filter_map(|command| {
let row = find_command(*command)?;
if row.safety_class != SafetyClass::SafeRead {
return None;
}
if !command_applies_to_pid(row, self.target.pid) {
return None;
}
if !is_command_allowed_by_family(self.profile.protocol_family, *command)
|| !is_command_allowed_by_capability(self.profile.capability, *command)
{
return None;
}
if self.profile.support_tier == SupportTier::CandidateReadOnly
&& !is_command_allowed_for_candidate_pid(
self.target.pid,
*command,
row.safety_class,
)
{
return None;
}
Some((*command, row.runtime_policy(), row.evidence_confidence()))
})
.collect()
}
fn run_diag_check(
&mut self,
command: CommandId,
runtime_policy: CommandRuntimePolicy,
confidence: EvidenceConfidence,
) -> DiagCommandStatus {
if command == CommandId::GetMode {
return self.run_diag_mode_check(runtime_policy, confidence);
}
match self.send_command(command, None) {
Ok(response) => {
let detail = diag_success_detail(command, &response.parsed_fields);
self.diag_success_status(
command,
runtime_policy,
confidence,
response.parsed_fields,
self.last_execution.clone(),
detail,
)
}
Err(err) => self.diag_failure_status(
command,
runtime_policy,
confidence,
err,
self.last_execution.clone(),
None,
),
}
}
fn run_diag_mode_check(
&mut self,
runtime_policy: CommandRuntimePolicy,
confidence: EvidenceConfidence,
) -> DiagCommandStatus {
match self.send_command(CommandId::GetMode, None) {
Ok(response) => {
let detail = diag_success_detail(CommandId::GetMode, &response.parsed_fields);
self.diag_success_status(
CommandId::GetMode,
runtime_policy,
confidence,
response.parsed_fields,
self.last_execution.clone(),
detail,
)
}
Err(primary_err) => {
let primary_detail = primary_err.to_string();
let primary_execution = self.last_execution.clone();
match self.send_command(CommandId::GetModeAlt, None) {
Ok(response) => self.diag_success_status(
CommandId::GetMode,
runtime_policy,
confidence,
response.parsed_fields,
self.last_execution.clone().or(primary_execution),
format!("ok via GetModeAlt fallback ({primary_detail})"),
),
Err(fallback_err) => self.diag_failure_status(
CommandId::GetMode,
runtime_policy,
confidence,
fallback_err,
self.last_execution.clone().or(primary_execution),
Some(format!(
"GetMode failed ({primary_detail}); GetModeAlt failed"
)),
),
}
}
}
}
fn diag_success_status(
&self,
command: CommandId,
runtime_policy: CommandRuntimePolicy,
confidence: EvidenceConfidence,
parsed_facts: BTreeMap<String, u32>,
execution: Option<CommandExecutionReport>,
detail: String,
) -> DiagCommandStatus {
let metadata = execution.as_ref();
DiagCommandStatus {
command,
ok: true,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: DiagSeverity::Ok,
attempts: metadata.map(|report| report.attempts).unwrap_or(0),
validator: metadata
.map(|report| report.validator.clone())
.unwrap_or_else(|| "unknown".to_owned()),
response_status: metadata
.map(|report| report.status.clone())
.unwrap_or(ResponseStatus::Ok),
bytes_written: metadata.map(|report| report.bytes_written).unwrap_or(0),
bytes_read: metadata.map(|report| report.bytes_read).unwrap_or(0),
error_code: None,
detail,
parsed_facts,
}
}
fn diag_failure_status(
&self,
command: CommandId,
runtime_policy: CommandRuntimePolicy,
confidence: EvidenceConfidence,
err: BitdoError,
execution: Option<CommandExecutionReport>,
detail_prefix: Option<String>,
) -> DiagCommandStatus {
let metadata = execution.as_ref();
let error_code = err.code();
let detail = if let Some(prefix) = detail_prefix {
format!("{prefix} ({err})")
} else {
err.to_string()
};
DiagCommandStatus {
command,
ok: false,
confidence,
is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate,
severity: classify_diag_failure(
command,
runtime_policy,
confidence,
error_code,
self.target.pid,
),
attempts: metadata.map(|report| report.attempts).unwrap_or(0),
validator: metadata
.map(|report| report.validator.clone())
.unwrap_or_else(|| "unknown".to_owned()),
response_status: metadata
.map(|report| report.status.clone())
.unwrap_or(ResponseStatus::Malformed),
bytes_written: metadata.map(|report| report.bytes_written).unwrap_or(0),
bytes_read: metadata.map(|report| report.bytes_read).unwrap_or(0),
error_code: Some(error_code),
detail,
parsed_facts: BTreeMap::new(),
}
}
pub fn get_mode(&mut self) -> Result<ModeState> {
let resp = self.send_command(CommandId::GetMode, None)?;
if let Some(mode) = resp.parsed_fields.get("mode").copied() {
@@ -1015,6 +1155,9 @@ fn parse_fields(command: CommandId, response: &[u8]) -> BTreeMap<String, u32> {
let pid = u16::from_le_bytes([response[22], response[23]]);
parsed.insert("detected_pid".to_owned(), pid as u32);
}
CommandId::GetReportRevision if response.len() >= 6 => {
parsed.insert("revision".to_owned(), response[5] as u32);
}
CommandId::GetMode | CommandId::GetModeAlt if response.len() >= 6 => {
parsed.insert("mode".to_owned(), response[5] as u32);
}
@@ -1047,3 +1190,40 @@ fn parse_indexed_u16_table(raw: &[u8], expected_items: usize) -> Vec<(u8, u16)>
out
}
fn diag_success_detail(command: CommandId, parsed_facts: &BTreeMap<String, u32>) -> String {
match command {
CommandId::GetPid => parsed_facts
.get("detected_pid")
.map(|pid| format!("detected pid {pid:#06x}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetReportRevision => parsed_facts
.get("revision")
.map(|revision| format!("report revision {revision}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetMode | CommandId::GetModeAlt => parsed_facts
.get("mode")
.map(|mode| format!("mode {mode}"))
.unwrap_or_else(|| "ok".to_owned()),
CommandId::GetControllerVersion | CommandId::Version => {
let version = parsed_facts.get("version_x100").copied();
let beta = parsed_facts.get("beta").copied();
match (version, beta) {
(Some(version_x100), Some(beta)) => format!(
"firmware {}.{:02} beta={beta}",
version_x100 / 100,
version_x100 % 100
),
(Some(version_x100), None) => {
format!("firmware {}.{:02}", version_x100 / 100, version_x100 % 100)
}
_ => "ok".to_owned(),
}
}
CommandId::U2GetCurrentSlot => parsed_facts
.get("slot")
.map(|slot| format!("current slot {slot}"))
.unwrap_or_else(|| "ok".to_owned()),
_ => "ok".to_owned(),
}
}

View File

@@ -9,11 +9,15 @@ anyhow = { workspace = true }
ratatui = { workspace = true }
crossterm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
fuzzy-matcher = { workspace = true }
unicode-width = { workspace = true }
bitdo_proto = { path = "../bitdo_proto" }
bitdo_app_core = { path = "../bitdo_app_core" }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }
insta = { workspace = true }

View File

@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuickAction {
Refresh,
Diagnose,
RunAgain,
SaveReport,
RecommendedUpdate,
EditMappings,
Settings,
Quit,
Confirm,
Cancel,
ApplyDraft,
UndoDraft,
ResetDraft,
RestoreBackup,
Firmware,
Back,
}
impl QuickAction {
pub fn label(self) -> &'static str {
match self {
QuickAction::Refresh => "Refresh",
QuickAction::Diagnose => "Diagnose",
QuickAction::RunAgain => "Run Again",
QuickAction::SaveReport => "Save Report",
QuickAction::RecommendedUpdate => "Recommended Update",
QuickAction::EditMappings => "Edit Mapping",
QuickAction::Settings => "Settings",
QuickAction::Quit => "Quit",
QuickAction::Confirm => "Confirm",
QuickAction::Cancel => "Cancel",
QuickAction::ApplyDraft => "Apply",
QuickAction::UndoDraft => "Undo",
QuickAction::ResetDraft => "Reset",
QuickAction::RestoreBackup => "Restore Backup",
QuickAction::Firmware => "Firmware",
QuickAction::Back => "Back",
}
}
}

View File

@@ -0,0 +1,69 @@
use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode};
use bitdo_app_core::{
ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdateSessionId,
U2CoreProfile,
};
use bitdo_proto::DiagProbeResult;
use bitdo_proto::VidPid;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub enum MappingApplyDraft {
Jp108(Vec<DedicatedButtonMapping>),
Ultimate2(U2CoreProfile),
}
#[derive(Clone, Debug)]
pub enum Effect {
RefreshDevices,
RunDiagnostics {
vid_pid: VidPid,
},
LoadMappings {
vid_pid: VidPid,
},
ApplyMappings {
vid_pid: VidPid,
draft: MappingApplyDraft,
},
RestoreBackup {
backup_id: ConfigBackupId,
},
PreparePreflight {
vid_pid: VidPid,
firmware_path_override: Option<PathBuf>,
allow_unsafe: bool,
brick_risk_ack: bool,
experimental: bool,
chunk_size: Option<usize>,
},
StartFirmware {
session_id: FirmwareUpdateSessionId,
acknowledged_risk: bool,
},
CancelFirmware {
session_id: FirmwareUpdateSessionId,
},
PollFirmwareReport {
session_id: FirmwareUpdateSessionId,
},
DeleteTempFile {
path: PathBuf,
},
PersistSettings {
path: PathBuf,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
device_filter_text: String,
dashboard_layout_mode: DashboardLayoutMode,
last_panel_focus: PanelFocus,
},
PersistSupportReport {
operation: String,
vid_pid: Option<VidPid>,
status: String,
message: String,
diag: Option<DiagProbeResult>,
firmware: Option<FirmwareFinalReport>,
},
}

View File

@@ -0,0 +1,85 @@
use crate::AppDevice;
use bitdo_app_core::{
ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareProgressEvent,
FirmwareUpdatePlan, U2CoreProfile,
};
use bitdo_proto::{DiagProbeResult, VidPid};
use std::path::PathBuf;
use super::action::QuickAction;
use super::state::DiagnosticsFilter;
#[derive(Clone, Debug)]
pub enum AppEvent {
Init,
Tick,
DeviceFilterSet(String),
DeviceFilterInput(char),
DeviceFilterBackspace,
SelectFilteredDevice(usize),
SelectNextDevice,
SelectPrevDevice,
SelectNextAction,
SelectPrevAction,
DiagnosticsSelectCheck(usize),
DiagnosticsSelectNextCheck,
DiagnosticsSelectPrevCheck,
DiagnosticsShiftFilter(i32),
DiagnosticsSetFilter(DiagnosticsFilter),
TriggerAction(QuickAction),
ConfirmPrimary,
Back,
Quit,
ToggleAdvancedMode,
CycleReportSaveMode,
MappingAdjust(i32),
MappingMoveSelection(i32),
DevicesLoaded(Vec<AppDevice>),
DevicesLoadFailed(String),
DiagnosticsCompleted {
vid_pid: VidPid,
result: DiagProbeResult,
summary: String,
},
DiagnosticsFailed {
vid_pid: VidPid,
error: String,
},
MappingsLoadedJp108 {
vid_pid: VidPid,
mappings: Vec<DedicatedButtonMapping>,
},
MappingsLoadedUltimate2 {
vid_pid: VidPid,
profile: U2CoreProfile,
},
MappingLoadFailed(String),
MappingApplied {
backup_id: Option<ConfigBackupId>,
message: String,
recovery_lock: bool,
},
MappingApplyFailed(String),
BackupRestoreCompleted(String),
BackupRestoreFailed(String),
PreflightReady {
vid_pid: VidPid,
firmware_path: PathBuf,
source: String,
version: String,
plan: FirmwareUpdatePlan,
downloaded_firmware_path: Option<PathBuf>,
},
PreflightBlocked(String),
UpdateStarted {
session_id: String,
source: String,
version: String,
},
UpdateProgress(FirmwareProgressEvent),
UpdateFinished(FirmwareFinalReport),
UpdateFailed(String),
SettingsPersisted,
SupportReportSaved(PathBuf),
Error(String),
}

View File

@@ -0,0 +1,5 @@
pub mod action;
pub mod effect;
pub mod event;
pub mod reducer;
pub mod state;

View File

@@ -0,0 +1,882 @@
use super::action::QuickAction;
use super::effect::{Effect, MappingApplyDraft};
use super::event::AppEvent;
use super::state::{
AppState, DiagnosticsFilter, DiagnosticsState, EventLevel, MappingDraftState, PanelFocus,
Screen, TaskMode, TaskState,
};
pub fn reduce(state: &mut AppState, event: AppEvent) -> Vec<Effect> {
let mut effects = Vec::new();
match event {
AppEvent::Init => {
state.append_event(EventLevel::Info, "Initializing dashboard");
effects.push(Effect::RefreshDevices);
}
AppEvent::Tick => {
if let Some(task) = state.task_state.as_ref() {
if matches!(task.mode, TaskMode::Updating) {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::PollFirmwareReport {
session_id: plan.session_id.clone(),
});
}
}
}
}
AppEvent::DeviceFilterSet(next) => {
state.device_filter = next;
state.selected_filtered_index = 0;
state.last_panel_focus = PanelFocus::Devices;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::DeviceFilterInput(ch) => {
state.device_filter.push(ch);
state.selected_filtered_index = 0;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::DeviceFilterBackspace => {
state.device_filter.pop();
state.selected_filtered_index = 0;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::SelectFilteredDevice(index) => {
state.select_filtered_index(index);
state.last_panel_focus = PanelFocus::Devices;
state.recompute_quick_actions();
}
AppEvent::SelectNextDevice => {
state.select_next_device();
}
AppEvent::SelectPrevDevice => {
state.select_prev_device();
}
AppEvent::SelectNextAction => {
state.select_next_action();
state.last_panel_focus = PanelFocus::QuickActions;
}
AppEvent::SelectPrevAction => {
state.select_prev_action();
state.last_panel_focus = PanelFocus::QuickActions;
}
AppEvent::DiagnosticsSelectCheck(index) => {
state.select_diagnostics_filtered_index(index);
}
AppEvent::DiagnosticsSelectNextCheck => {
state.select_next_diagnostics_check();
}
AppEvent::DiagnosticsSelectPrevCheck => {
state.select_prev_diagnostics_check();
}
AppEvent::DiagnosticsShiftFilter(delta) => {
state.shift_diagnostics_filter(delta);
}
AppEvent::DiagnosticsSetFilter(filter) => {
state.set_diagnostics_filter(filter);
}
AppEvent::TriggerAction(action) => {
effects.extend(handle_action(state, action));
}
AppEvent::ConfirmPrimary => {
if let Some(action) = state.selected_action() {
effects.extend(handle_action(state, action));
}
}
AppEvent::Back => {
let keep_task_state = state
.task_state
.as_ref()
.map(|task| task.mode == TaskMode::Updating)
.unwrap_or(false);
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.screen = Screen::Dashboard;
if !keep_task_state {
state.task_state = None;
} else {
state.set_status("Firmware update continues in background");
}
state.diagnostics_state = None;
state.mapping_draft_state = None;
state.recompute_quick_actions();
}
AppEvent::Quit => {
state.quit_requested = true;
}
AppEvent::ToggleAdvancedMode => {
state.advanced_mode = !state.advanced_mode;
if !state.advanced_mode && state.report_save_mode == crate::ReportSaveMode::Off {
state.report_save_mode = crate::ReportSaveMode::FailureOnly;
}
state.append_event(
EventLevel::Info,
if state.advanced_mode {
"Advanced mode enabled"
} else {
"Advanced mode disabled"
},
);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
state.recompute_quick_actions();
}
AppEvent::CycleReportSaveMode => {
state.report_save_mode = state.report_save_mode.next(state.advanced_mode);
state.append_event(
EventLevel::Info,
format!(
"Report save mode changed to {}",
state.report_save_mode.as_str()
),
);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::MappingMoveSelection(delta) => {
if let Some(mapping) = state.mapping_draft_state.as_mut() {
match mapping {
MappingDraftState::Jp108 {
selected_row,
current,
..
} => {
if current.is_empty() {
return effects;
}
let len = current.len() as i32;
let mut idx = *selected_row as i32 + delta;
while idx < 0 {
idx += len;
}
*selected_row = (idx % len) as usize;
}
MappingDraftState::Ultimate2 {
selected_row,
current,
..
} => {
if current.mappings.is_empty() {
return effects;
}
let len = current.mappings.len() as i32;
let mut idx = *selected_row as i32 + delta;
while idx < 0 {
idx += len;
}
*selected_row = (idx % len) as usize;
}
}
}
state.recompute_quick_actions();
}
AppEvent::MappingAdjust(delta) => {
adjust_mapping(state, delta);
state.recompute_quick_actions();
}
AppEvent::DevicesLoaded(devices) => {
state.devices = devices;
let filtered = state.filtered_device_indices();
if filtered.is_empty() {
state.selected_device_id = None;
state.selected_filtered_index = 0;
state.set_status("No controller detected");
} else {
let selected = filtered
.get(state.selected_filtered_index)
.copied()
.unwrap_or(filtered[0]);
state.selected_filtered_index =
state.selected_filtered_index.min(filtered.len() - 1);
state.selected_device_id = Some(state.devices[selected].vid_pid);
state.set_status("Controllers refreshed");
}
state.append_event(
EventLevel::Info,
format!("Device refresh complete ({} found)", state.devices.len()),
);
state.recompute_quick_actions();
}
AppEvent::DevicesLoadFailed(err) => {
state.set_status(format!("Refresh failed: {err}"));
state.append_event(EventLevel::Error, format!("Refresh failed: {err}"));
}
AppEvent::DiagnosticsCompleted {
vid_pid,
result,
summary,
} => {
let check_count = result.command_checks.len();
state.screen = Screen::Diagnostics;
state.task_state = None;
state.diagnostics_state = Some(DiagnosticsState {
result: result.clone(),
summary: summary.clone(),
selected_check_index: 0,
active_filter: DiagnosticsFilter::All,
latest_report_path: None,
});
state.ensure_diagnostics_selection();
state.set_status("Diagnostics complete");
state.append_event(
EventLevel::Info,
format!("Diagnostics complete for {vid_pid} ({check_count} checks)"),
);
if crate::should_save_support_report(state.report_save_mode, false) {
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(vid_pid),
status: "ok".to_owned(),
message: summary,
diag: Some(result),
firmware: None,
});
}
state.recompute_quick_actions();
}
AppEvent::DiagnosticsFailed { vid_pid, error } => {
state.screen = Screen::Task;
state.diagnostics_state = None;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Diagnostics failed for {vid_pid}: {error}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Diagnostics failed");
state.append_event(
EventLevel::Error,
format!("Diagnostics failed for {vid_pid}: {error}"),
);
if crate::should_save_support_report(state.report_save_mode, true) {
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(vid_pid),
status: "failed".to_owned(),
message: error,
diag: None,
firmware: None,
});
}
state.recompute_quick_actions();
}
AppEvent::MappingsLoadedJp108 { vid_pid, mappings } => {
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: mappings.clone(),
current: mappings,
undo_stack: Vec::new(),
selected_row: 0,
});
state.append_event(
EventLevel::Info,
format!("Loaded JP108 mappings for {vid_pid}"),
);
state.set_status("Mapping draft loaded");
state.recompute_quick_actions();
}
AppEvent::MappingsLoadedUltimate2 { vid_pid, profile } => {
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Ultimate2 {
loaded: profile.clone(),
current: profile,
undo_stack: Vec::new(),
selected_row: 0,
});
state.append_event(
EventLevel::Info,
format!("Loaded Ultimate2 profile mapping for {vid_pid}"),
);
state.set_status("Mapping draft loaded");
state.recompute_quick_actions();
}
AppEvent::MappingLoadFailed(err) => {
state.set_status(format!("Mapping load failed: {err}"));
state.append_event(EventLevel::Error, format!("Mapping load failed: {err}"));
}
AppEvent::MappingApplied {
backup_id,
message,
recovery_lock,
} => {
state.latest_backup = backup_id;
if recovery_lock {
state.write_lock_until_restart = true;
state.screen = Screen::Recovery;
state.set_status("Write lock enabled until restart");
state.append_event(EventLevel::Error, message);
} else {
state.set_status("Mapping applied");
state.append_event(EventLevel::Info, message);
}
state.recompute_quick_actions();
}
AppEvent::MappingApplyFailed(err) => {
state.set_status(format!("Apply failed: {err}"));
state.append_event(EventLevel::Error, format!("Apply failed: {err}"));
}
AppEvent::BackupRestoreCompleted(message) => {
state.set_status("Backup restored");
state.append_event(EventLevel::Info, message);
}
AppEvent::BackupRestoreFailed(message) => {
state.set_status("Backup restore failed");
state.append_event(EventLevel::Error, message);
}
AppEvent::PreflightReady {
vid_pid,
firmware_path,
source,
version,
plan,
downloaded_firmware_path,
} => {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: Some(plan.clone()),
progress: 0,
status: format!(
"Ready to update {vid_pid} to {version} from {}",
firmware_path.display()
),
final_report: None,
downloaded_firmware_path,
});
state.append_event(
EventLevel::Info,
format!("Preflight passed ({source}, {version})"),
);
state.set_status("Preflight ready, confirm to acknowledge risk and start");
state.recompute_quick_actions();
}
AppEvent::PreflightBlocked(reason) => {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Preflight blocked: {reason}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Preflight blocked");
state.append_event(EventLevel::Warning, reason);
state.recompute_quick_actions();
}
AppEvent::UpdateStarted {
session_id,
source,
version,
} => {
if let Some(task) = state.task_state.as_mut() {
task.mode = TaskMode::Updating;
task.progress = 1;
task.status =
format!("Session {session_id}: updating from {source} (target {version})");
} else {
state.task_state = Some(TaskState {
mode: TaskMode::Updating,
plan: None,
progress: 1,
status: format!("Session {session_id}: update started"),
final_report: None,
downloaded_firmware_path: None,
});
}
state.screen = Screen::Task;
state.append_event(EventLevel::Info, "Firmware transfer started");
state.set_status("Firmware update in progress");
state.recompute_quick_actions();
}
AppEvent::UpdateProgress(progress_event) => {
if let Some(task) = state.task_state.as_mut() {
task.mode = TaskMode::Updating;
task.progress = progress_event.progress;
task.status = format!("{}: {}", progress_event.stage, progress_event.message);
}
state.append_event(
EventLevel::Info,
format!(
"{}% {}: {}",
progress_event.progress, progress_event.stage, progress_event.message
),
);
}
AppEvent::UpdateFinished(report) => {
let downloaded_firmware_path = take_any_downloaded_firmware_path(state);
let failed = report.status != bitdo_app_core::FirmwareOutcome::Completed;
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Update {:?}: {}", report.status, report.message),
final_report: Some(report.clone()),
downloaded_firmware_path: None,
});
state.set_status(format!("Update {:?}", report.status));
state.append_event(
if failed {
EventLevel::Error
} else {
EventLevel::Info
},
format!(
"Update {:?} (chunks {}/{})",
report.status, report.chunks_sent, report.chunks_total
),
);
if crate::should_save_support_report(state.report_save_mode, failed) {
effects.push(Effect::PersistSupportReport {
operation: "fw-write".to_owned(),
vid_pid: state.selected_device().map(|d| d.vid_pid),
status: if failed {
"failed".to_owned()
} else {
"completed".to_owned()
},
message: report.message.clone(),
diag: None,
firmware: Some(report),
});
}
if let Some(path) = downloaded_firmware_path {
effects.push(Effect::DeleteTempFile { path });
}
state.recompute_quick_actions();
}
AppEvent::UpdateFailed(err) => {
let downloaded_firmware_path = take_any_downloaded_firmware_path(state);
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Update failed: {err}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Update failed");
state.append_event(EventLevel::Error, format!("Update failed: {err}"));
if let Some(path) = downloaded_firmware_path {
effects.push(Effect::DeleteTempFile { path });
}
state.recompute_quick_actions();
}
AppEvent::SettingsPersisted => {
state.append_event(EventLevel::Info, "Settings saved");
}
AppEvent::SupportReportSaved(path) => {
state.latest_report_path = Some(path.clone());
if let Some(diagnostics) = state.diagnostics_state.as_mut() {
diagnostics.latest_report_path = Some(path.clone());
}
state.append_event(
EventLevel::Info,
format!("Support report saved: {}", path.display()),
);
}
AppEvent::Error(message) => {
state.set_status(message.clone());
state.append_event(EventLevel::Error, message);
}
}
effects
}
fn handle_action(state: &mut AppState, action: QuickAction) -> Vec<Effect> {
let mut effects = Vec::new();
match state.screen {
Screen::Dashboard => match action {
QuickAction::Refresh => {
effects.push(Effect::RefreshDevices);
}
QuickAction::Diagnose => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Diagnostics,
plan: None,
progress: 5,
status: format!("Running diagnostics for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
state.diagnostics_state = None;
effects.push(Effect::RunDiagnostics { vid_pid });
}
}
QuickAction::RecommendedUpdate => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 0,
status: format!("Preparing preflight for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
effects.push(Effect::PreparePreflight {
vid_pid,
firmware_path_override: state.firmware_path_override.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: state.experimental,
chunk_size: state.chunk_size,
});
}
}
QuickAction::EditMappings => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
effects.push(Effect::LoadMappings { vid_pid });
}
}
QuickAction::Settings => {
state.screen = Screen::Settings;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Task => match action {
QuickAction::Confirm => {
if let Some(task) = state.task_state.as_ref() {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::StartFirmware {
session_id: plan.session_id.clone(),
acknowledged_risk: true,
});
}
}
}
QuickAction::Cancel => {
if let Some(task) = state.task_state.as_ref() {
if task.mode == TaskMode::Updating {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::CancelFirmware {
session_id: plan.session_id.clone(),
});
} else {
state.screen = Screen::Dashboard;
state.task_state = None;
}
} else {
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.screen = Screen::Dashboard;
state.task_state = None;
}
} else {
state.screen = Screen::Dashboard;
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
if state
.task_state
.as_ref()
.map(|task| task.mode == TaskMode::Updating)
.unwrap_or(false)
{
state.set_status("Firmware update continues in background");
} else {
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.task_state = None;
}
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Diagnostics => match action {
QuickAction::RunAgain => {
let vid_pid = state
.diagnostics_state
.as_ref()
.map(|diagnostics| diagnostics.result.target)
.or_else(|| state.selected_device().map(|device| device.vid_pid));
if let Some(vid_pid) = vid_pid {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Diagnostics,
plan: None,
progress: 5,
status: format!("Running diagnostics for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
state.diagnostics_state = None;
effects.push(Effect::RunDiagnostics { vid_pid });
}
}
QuickAction::SaveReport => {
if let Some(diagnostics) = state.diagnostics_state.as_ref() {
let has_issues =
diagnostics.result.command_checks.iter().any(|check| {
!check.ok || check.severity != bitdo_proto::DiagSeverity::Ok
});
let target = diagnostics.result.target;
let result = diagnostics.result.clone();
let summary = diagnostics.summary.clone();
state.set_status("Saving diagnostics report");
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(target),
status: if has_issues {
"attention".to_owned()
} else {
"ok".to_owned()
},
message: summary,
diag: Some(result),
firmware: None,
});
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
state.task_state = None;
state.diagnostics_state = None;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::MappingEditor => match action {
QuickAction::ApplyDraft => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
if let Some(draft) = state.mapping_draft_state.as_ref() {
let payload = match draft {
MappingDraftState::Jp108 { current, .. } => {
MappingApplyDraft::Jp108(current.clone())
}
MappingDraftState::Ultimate2 { current, .. } => {
MappingApplyDraft::Ultimate2(current.clone())
}
};
effects.push(Effect::ApplyMappings {
vid_pid,
draft: payload,
});
}
}
}
QuickAction::UndoDraft => {
mapping_undo(state);
}
QuickAction::ResetDraft => {
mapping_reset(state);
}
QuickAction::RestoreBackup => {
if let Some(backup) = state.latest_backup.clone() {
effects.push(Effect::RestoreBackup { backup_id: backup });
}
}
QuickAction::Firmware => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 0,
status: format!("Preparing preflight for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
effects.push(Effect::PreparePreflight {
vid_pid,
firmware_path_override: state.firmware_path_override.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: state.experimental,
chunk_size: state.chunk_size,
});
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
state.mapping_draft_state = None;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Recovery => match action {
QuickAction::RestoreBackup => {
if let Some(backup) = state.latest_backup.clone() {
effects.push(Effect::RestoreBackup { backup_id: backup });
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Settings => match action {
QuickAction::Back => state.screen = Screen::Dashboard,
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
}
state.recompute_quick_actions();
effects
}
fn persist_settings_effect(state: &AppState) -> Option<Effect> {
state
.settings_path
.clone()
.map(|path| Effect::PersistSettings {
path,
advanced_mode: state.advanced_mode,
report_save_mode: state.report_save_mode,
device_filter_text: state.device_filter.clone(),
dashboard_layout_mode: state.dashboard_layout_mode,
last_panel_focus: state.last_panel_focus,
})
}
fn mapping_undo(state: &mut AppState) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
current,
undo_stack,
..
}) => {
if let Some(previous) = undo_stack.pop() {
*current = previous;
}
}
Some(MappingDraftState::Ultimate2 {
current,
undo_stack,
..
}) => {
if let Some(previous) = undo_stack.pop() {
*current = previous;
}
}
None => {}
}
}
fn mapping_reset(state: &mut AppState) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
loaded,
current,
undo_stack,
..
}) => {
undo_stack.push(current.clone());
*current = loaded.clone();
}
Some(MappingDraftState::Ultimate2 {
loaded,
current,
undo_stack,
..
}) => {
undo_stack.push(current.clone());
*current = loaded.clone();
}
None => {}
}
}
fn adjust_mapping(state: &mut AppState, delta: i32) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
current,
undo_stack,
selected_row,
..
}) => {
if *selected_row < current.len() {
undo_stack.push(current.clone());
let entry = &mut current[*selected_row];
entry.target_hid_usage = cycle_jp108(entry.target_hid_usage, delta);
}
}
Some(MappingDraftState::Ultimate2 {
current,
undo_stack,
selected_row,
..
}) => {
if *selected_row < current.mappings.len() {
undo_stack.push(current.clone());
let entry = &mut current.mappings[*selected_row];
entry.target_hid_usage = cycle_u2(entry.target_hid_usage, delta);
}
}
None => {}
}
}
fn take_any_downloaded_firmware_path(state: &mut AppState) -> Option<std::path::PathBuf> {
state
.task_state
.as_mut()
.and_then(|task| task.downloaded_firmware_path.take())
}
fn take_cleanup_path_for_navigation(state: &mut AppState) -> Option<std::path::PathBuf> {
let should_cleanup = state
.task_state
.as_ref()
.map(|task| task.mode != TaskMode::Updating)
.unwrap_or(false);
if should_cleanup {
take_any_downloaded_firmware_path(state)
} else {
None
}
}
const JP108_PRESETS: [u16; 16] = [
0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x0028, 0x0029, 0x002c, 0x003a,
0x003b, 0x003c, 0x00e0, 0x00e1,
];
const U2_PRESETS: [u16; 17] = [
0x0100, 0x0101, 0x0102, 0x0103, 0x0104, 0x0105, 0x0106, 0x0107, 0x0108, 0x0109, 0x010a, 0x010b,
0x010c, 0x010d, 0x010e, 0x010f, 0x0110,
];
fn cycle_jp108(current: u16, delta: i32) -> u16 {
cycle_from_table(&JP108_PRESETS, current, delta)
}
fn cycle_u2(current: u16, delta: i32) -> u16 {
cycle_from_table(&U2_PRESETS, current, delta)
}
fn cycle_from_table(table: &[u16], current: u16, delta: i32) -> u16 {
let pos = table.iter().position(|item| *item == current).unwrap_or(0) as i32;
let len = table.len() as i32;
let mut next = pos + delta;
while next < 0 {
next += len;
}
table[(next as usize) % table.len()]
}

View File

@@ -0,0 +1,634 @@
use crate::{AppDevice, BuildInfo, ReportSaveMode, UiLaunchOptions};
use bitdo_app_core::{
ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdatePlan, U2CoreProfile,
};
use bitdo_proto::{DiagCommandStatus, DiagProbeResult, DiagSeverity, SupportTier, VidPid};
use chrono::Utc;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use super::action::QuickAction;
pub const EVENT_LOG_CAPACITY: usize = 200;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Screen {
Dashboard,
Task,
Diagnostics,
MappingEditor,
Recovery,
Settings,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DashboardLayoutMode {
#[default]
Wide,
Compact,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PanelFocus {
#[default]
Devices,
QuickActions,
EventLog,
Status,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventLevel {
Info,
Warning,
Error,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct EventEntry {
pub timestamp_utc: String,
pub level: EventLevel,
pub message: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskMode {
Diagnostics,
Preflight,
Updating,
Final,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaskState {
pub mode: TaskMode,
pub plan: Option<FirmwareUpdatePlan>,
pub progress: u8,
pub status: String,
pub final_report: Option<FirmwareFinalReport>,
pub downloaded_firmware_path: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticsFilter {
#[default]
All,
Issues,
Experimental,
}
impl DiagnosticsFilter {
pub const ALL: [DiagnosticsFilter; 3] = [
DiagnosticsFilter::All,
DiagnosticsFilter::Issues,
DiagnosticsFilter::Experimental,
];
pub fn label(self) -> &'static str {
match self {
DiagnosticsFilter::All => "All",
DiagnosticsFilter::Issues => "Issues",
DiagnosticsFilter::Experimental => "Experimental",
}
}
pub fn matches(self, check: &DiagCommandStatus) -> bool {
match self {
DiagnosticsFilter::All => true,
DiagnosticsFilter::Issues => !check.ok || check.severity != DiagSeverity::Ok,
DiagnosticsFilter::Experimental => check.is_experimental,
}
}
pub fn shift(self, delta: i32) -> Self {
let current = Self::ALL
.iter()
.position(|candidate| *candidate == self)
.unwrap_or(0) as i32;
let len = Self::ALL.len() as i32;
let mut next = current + delta;
while next < 0 {
next += len;
}
Self::ALL[(next as usize) % Self::ALL.len()]
}
}
#[derive(Clone, Debug)]
pub struct DiagnosticsState {
pub result: DiagProbeResult,
pub summary: String,
pub selected_check_index: usize,
pub active_filter: DiagnosticsFilter,
pub latest_report_path: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MappingEditorKind {
Jp108,
Ultimate2,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum MappingDraftState {
Jp108 {
loaded: Vec<DedicatedButtonMapping>,
current: Vec<DedicatedButtonMapping>,
undo_stack: Vec<Vec<DedicatedButtonMapping>>,
selected_row: usize,
},
Ultimate2 {
loaded: U2CoreProfile,
current: U2CoreProfile,
undo_stack: Vec<U2CoreProfile>,
selected_row: usize,
},
}
#[derive(Clone, Debug)]
pub struct QuickActionState {
pub action: QuickAction,
pub enabled: bool,
pub reason: Option<String>,
}
#[derive(Clone, Debug)]
pub struct AppState {
pub screen: Screen,
pub build_info: BuildInfo,
pub advanced_mode: bool,
pub report_save_mode: ReportSaveMode,
pub settings_path: Option<PathBuf>,
pub dashboard_layout_mode: DashboardLayoutMode,
pub last_panel_focus: PanelFocus,
pub devices: Vec<AppDevice>,
pub selected_device_id: Option<VidPid>,
pub selected_filtered_index: usize,
pub device_filter: String,
pub quick_actions: Vec<QuickActionState>,
pub selected_action_index: usize,
pub event_log: VecDeque<EventEntry>,
pub task_state: Option<TaskState>,
pub diagnostics_state: Option<DiagnosticsState>,
pub mapping_draft_state: Option<MappingDraftState>,
pub latest_backup: Option<ConfigBackupId>,
pub write_lock_until_restart: bool,
pub latest_report_path: Option<PathBuf>,
pub status_line: String,
pub firmware_path_override: Option<PathBuf>,
pub allow_unsafe: bool,
pub brick_risk_ack: bool,
pub experimental: bool,
pub chunk_size: Option<usize>,
pub quit_requested: bool,
}
impl AppState {
pub fn new(opts: &UiLaunchOptions) -> Self {
let mut state = Self {
screen: Screen::Dashboard,
build_info: opts.build_info.clone(),
advanced_mode: opts.advanced_mode,
report_save_mode: if !opts.advanced_mode && opts.report_save_mode == ReportSaveMode::Off
{
ReportSaveMode::FailureOnly
} else {
opts.report_save_mode
},
settings_path: opts.settings_path.clone(),
dashboard_layout_mode: DashboardLayoutMode::Wide,
last_panel_focus: PanelFocus::Devices,
devices: Vec::new(),
selected_device_id: None,
selected_filtered_index: 0,
device_filter: String::new(),
quick_actions: Vec::new(),
selected_action_index: 0,
event_log: VecDeque::with_capacity(EVENT_LOG_CAPACITY),
task_state: None,
diagnostics_state: None,
mapping_draft_state: None,
latest_backup: None,
write_lock_until_restart: false,
latest_report_path: None,
status_line: "OpenBitdo ready".to_owned(),
firmware_path_override: opts.firmware_path.clone(),
allow_unsafe: opts.allow_unsafe,
brick_risk_ack: opts.brick_risk_ack,
experimental: opts.experimental,
chunk_size: opts.chunk_size,
quit_requested: false,
};
state.recompute_quick_actions();
state
}
pub fn set_layout_from_size(&mut self, width: u16, height: u16) {
self.dashboard_layout_mode = if width < 80 || height < 24 {
DashboardLayoutMode::Compact
} else {
DashboardLayoutMode::Wide
};
}
pub fn filtered_device_indices(&self) -> Vec<usize> {
if self.device_filter.trim().is_empty() {
return (0..self.devices.len()).collect();
}
let query = self.device_filter.to_lowercase();
let matcher = SkimMatcherV2::default();
let mut scored: Vec<(i64, usize)> = self
.devices
.iter()
.enumerate()
.filter_map(|(idx, dev)| {
let text = format!(
"{:04x}:{:04x} {}",
dev.vid_pid.vid,
dev.vid_pid.pid,
dev.name.to_lowercase()
);
matcher.fuzzy_match(&text, &query).map(|score| (score, idx))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
scored.into_iter().map(|(_, idx)| idx).collect()
}
pub fn selected_device(&self) -> Option<&AppDevice> {
self.selected_device_id
.and_then(|id| self.devices.iter().find(|d| d.vid_pid == id))
.or_else(|| self.devices.first())
}
pub fn select_filtered_index(&mut self, index: usize) {
let filtered = self.filtered_device_indices();
if let Some(device_idx) = filtered.get(index).copied() {
self.selected_filtered_index = index;
self.selected_device_id = Some(self.devices[device_idx].vid_pid);
self.recompute_quick_actions();
}
}
pub fn select_next_device(&mut self) {
let filtered = self.filtered_device_indices();
if filtered.is_empty() {
return;
}
self.selected_filtered_index = (self.selected_filtered_index + 1) % filtered.len();
self.select_filtered_index(self.selected_filtered_index);
}
pub fn select_prev_device(&mut self) {
let filtered = self.filtered_device_indices();
if filtered.is_empty() {
return;
}
if self.selected_filtered_index == 0 {
self.selected_filtered_index = filtered.len().saturating_sub(1);
} else {
self.selected_filtered_index -= 1;
}
self.select_filtered_index(self.selected_filtered_index);
}
pub fn append_event(&mut self, level: EventLevel, message: impl Into<String>) {
if self.event_log.len() >= EVENT_LOG_CAPACITY {
self.event_log.pop_front();
}
self.event_log.push_back(EventEntry {
timestamp_utc: Utc::now().format("%H:%M:%S").to_string(),
level,
message: message.into(),
});
}
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_line = message.into();
}
pub fn select_next_action(&mut self) {
if self.quick_actions.is_empty() {
return;
}
self.selected_action_index = (self.selected_action_index + 1) % self.quick_actions.len();
}
pub fn select_prev_action(&mut self) {
if self.quick_actions.is_empty() {
return;
}
if self.selected_action_index == 0 {
self.selected_action_index = self.quick_actions.len().saturating_sub(1);
} else {
self.selected_action_index -= 1;
}
}
pub fn selected_action(&self) -> Option<QuickAction> {
self.quick_actions
.get(self.selected_action_index)
.filter(|a| a.enabled)
.map(|a| a.action)
}
pub fn diagnostics_filtered_indices(&self) -> Vec<usize> {
let Some(diagnostics) = self.diagnostics_state.as_ref() else {
return Vec::new();
};
diagnostics
.result
.command_checks
.iter()
.enumerate()
.filter_map(|(idx, check)| diagnostics.active_filter.matches(check).then_some(idx))
.collect()
}
pub fn selected_diagnostics_check(&self) -> Option<&DiagCommandStatus> {
let diagnostics = self.diagnostics_state.as_ref()?;
diagnostics
.result
.command_checks
.get(diagnostics.selected_check_index)
}
pub fn select_diagnostics_filtered_index(&mut self, filtered_index: usize) {
let filtered = self.diagnostics_filtered_indices();
if let Some(check_index) = filtered.get(filtered_index).copied() {
if let Some(diagnostics) = self.diagnostics_state.as_mut() {
diagnostics.selected_check_index = check_index;
}
}
}
pub fn select_next_diagnostics_check(&mut self) {
let filtered = self.diagnostics_filtered_indices();
if filtered.is_empty() {
return;
}
let current = self
.diagnostics_state
.as_ref()
.and_then(|diagnostics| {
filtered
.iter()
.position(|idx| *idx == diagnostics.selected_check_index)
})
.unwrap_or(0);
let next = (current + 1) % filtered.len();
self.select_diagnostics_filtered_index(next);
}
pub fn select_prev_diagnostics_check(&mut self) {
let filtered = self.diagnostics_filtered_indices();
if filtered.is_empty() {
return;
}
let current = self
.diagnostics_state
.as_ref()
.and_then(|diagnostics| {
filtered
.iter()
.position(|idx| *idx == diagnostics.selected_check_index)
})
.unwrap_or(0);
let next = if current == 0 {
filtered.len().saturating_sub(1)
} else {
current - 1
};
self.select_diagnostics_filtered_index(next);
}
pub fn set_diagnostics_filter(&mut self, filter: DiagnosticsFilter) {
if let Some(diagnostics) = self.diagnostics_state.as_mut() {
diagnostics.active_filter = filter;
}
self.ensure_diagnostics_selection();
}
pub fn shift_diagnostics_filter(&mut self, delta: i32) {
if let Some(diagnostics) = self.diagnostics_state.as_mut() {
diagnostics.active_filter = diagnostics.active_filter.shift(delta);
}
self.ensure_diagnostics_selection();
}
pub fn ensure_diagnostics_selection(&mut self) {
let filtered = self.diagnostics_filtered_indices();
let Some(diagnostics) = self.diagnostics_state.as_mut() else {
return;
};
if filtered.is_empty() {
diagnostics.selected_check_index = 0;
} else if !filtered.contains(&diagnostics.selected_check_index) {
diagnostics.selected_check_index = filtered[0];
}
}
pub fn recompute_quick_actions(&mut self) {
self.quick_actions = if matches!(self.screen, Screen::Dashboard) {
let actions = vec![
QuickActionState {
action: QuickAction::Refresh,
enabled: true,
reason: None,
},
QuickActionState {
action: QuickAction::Diagnose,
enabled: self.selected_device().is_some(),
reason: None,
},
QuickActionState {
action: QuickAction::RecommendedUpdate,
enabled: self
.selected_device()
.map(|d| d.support_tier == SupportTier::Full)
.unwrap_or(false)
&& !self.write_lock_until_restart,
reason: self.selected_device().and_then(|d| {
if d.support_tier != SupportTier::Full {
Some("Read-only until hardware confirmation".to_owned())
} else if self.write_lock_until_restart {
Some("Write locked until restart".to_owned())
} else {
None
}
}),
},
QuickActionState {
action: QuickAction::EditMappings,
enabled: self
.selected_device()
.map(|d| {
(d.capability.supports_jp108_dedicated_map
|| (d.capability.supports_u2_button_map
&& d.capability.supports_u2_slot_config))
&& d.support_tier == SupportTier::Full
&& !self.write_lock_until_restart
})
.unwrap_or(false),
reason: None,
},
QuickActionState {
action: QuickAction::Settings,
enabled: true,
reason: None,
},
QuickActionState {
action: QuickAction::Quit,
enabled: true,
reason: None,
},
];
if self.selected_action_index >= actions.len() {
self.selected_action_index = 0;
}
actions
} else if matches!(self.screen, Screen::Task) {
vec![
QuickActionState {
action: QuickAction::Confirm,
enabled: self
.task_state
.as_ref()
.map(|task| matches!(task.mode, TaskMode::Preflight))
.unwrap_or(false),
reason: None,
},
QuickActionState {
action: QuickAction::Cancel,
enabled: true,
reason: None,
},
QuickActionState {
action: QuickAction::Back,
enabled: true,
reason: None,
},
]
} else if matches!(self.screen, Screen::Diagnostics) {
vec![
QuickActionState {
action: QuickAction::RunAgain,
enabled: self.selected_device().is_some(),
reason: None,
},
QuickActionState {
action: QuickAction::SaveReport,
enabled: self.diagnostics_state.is_some(),
reason: None,
},
QuickActionState {
action: QuickAction::Back,
enabled: true,
reason: None,
},
]
} else if matches!(self.screen, Screen::MappingEditor) {
vec![
QuickActionState {
action: QuickAction::ApplyDraft,
enabled: !self.write_lock_until_restart,
reason: None,
},
QuickActionState {
action: QuickAction::UndoDraft,
enabled: self.mapping_can_undo(),
reason: None,
},
QuickActionState {
action: QuickAction::ResetDraft,
enabled: self.mapping_has_changes(),
reason: None,
},
QuickActionState {
action: QuickAction::RestoreBackup,
enabled: self.latest_backup.is_some(),
reason: None,
},
QuickActionState {
action: QuickAction::Firmware,
enabled: !self.write_lock_until_restart,
reason: None,
},
QuickActionState {
action: QuickAction::Back,
enabled: true,
reason: None,
},
]
} else if matches!(self.screen, Screen::Recovery) {
vec![
QuickActionState {
action: QuickAction::RestoreBackup,
enabled: self.latest_backup.is_some(),
reason: None,
},
QuickActionState {
action: QuickAction::Back,
enabled: true,
reason: None,
},
QuickActionState {
action: QuickAction::Quit,
enabled: true,
reason: None,
},
]
} else {
vec![
QuickActionState {
action: QuickAction::Back,
enabled: true,
reason: None,
},
QuickActionState {
action: QuickAction::Quit,
enabled: true,
reason: None,
},
]
};
if self.selected_action_index >= self.quick_actions.len() {
self.selected_action_index = 0;
}
}
pub fn mapping_can_undo(&self) -> bool {
match self.mapping_draft_state.as_ref() {
Some(MappingDraftState::Jp108 { undo_stack, .. }) => !undo_stack.is_empty(),
Some(MappingDraftState::Ultimate2 { undo_stack, .. }) => !undo_stack.is_empty(),
None => false,
}
}
pub fn mapping_has_changes(&self) -> bool {
match self.mapping_draft_state.as_ref() {
Some(MappingDraftState::Jp108 {
loaded, current, ..
}) => loaded != current,
Some(MappingDraftState::Ultimate2 {
loaded, current, ..
}) => loaded != current,
None => false,
}
}
}

View File

@@ -0,0 +1,301 @@
use crate::support_report::persist_support_report;
use crate::{should_save_support_report, HeadlessOutputMode, RunLaunchOptions};
use anyhow::{anyhow, Result};
use bitdo_app_core::{
FirmwareCancelRequest, FirmwareConfirmRequest, FirmwareFinalReport, FirmwareOutcome,
FirmwarePreflightRequest, FirmwareStartRequest, OpenBitdoCore,
};
use serde::Serialize;
use std::path::Path;
use tokio::time::{sleep, Duration};
#[derive(Serialize)]
struct JsonProgress<'a> {
r#type: &'static str,
session_id: &'a str,
sequence: u64,
stage: &'a str,
progress: u8,
message: &'a str,
timestamp: String,
}
#[derive(Serialize)]
struct JsonFinal<'a> {
r#type: &'static str,
session_id: &'a str,
status: &'a str,
chunks_sent: usize,
chunks_total: usize,
message: &'a str,
error_code: Option<String>,
}
pub async fn run_headless(
core: OpenBitdoCore,
opts: RunLaunchOptions,
) -> Result<FirmwareFinalReport> {
let downloaded_firmware = opts.firmware_path.is_none() && opts.use_recommended;
let firmware_path = if let Some(path) = opts.firmware_path.clone() {
path
} else if opts.use_recommended {
core.download_recommended_firmware(opts.vid_pid)
.await
.map(|d| d.firmware_path)
.map_err(|err| anyhow!("recommended firmware unavailable: {err}"))?
} else {
return Err(anyhow!(
"firmware path is required when --recommended is not used"
));
};
let preflight = match core
.preflight_firmware(FirmwarePreflightRequest {
vid_pid: opts.vid_pid,
firmware_path: firmware_path.clone(),
allow_unsafe: opts.allow_unsafe,
brick_risk_ack: opts.brick_risk_ack,
experimental: opts.experimental,
chunk_size: opts.chunk_size,
})
.await
{
Ok(preflight) => preflight,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
if !preflight.gate.allowed {
let message = preflight
.gate
.message
.unwrap_or_else(|| "policy denied".to_owned());
emit_failed_final(&opts, "preflight", &message);
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(anyhow!("preflight denied: {message}"));
}
let plan = match preflight.plan {
Some(plan) => plan,
None => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(anyhow!("preflight allowed without transfer plan"));
}
};
if let Err(err) = core
.start_firmware(FirmwareStartRequest {
session_id: plan.session_id.clone(),
})
.await
{
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
if let Err(err) = core
.confirm_firmware(FirmwareConfirmRequest {
session_id: plan.session_id.clone(),
acknowledged_risk: opts.acknowledged_risk,
})
.await
{
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
let mut events = match core.subscribe_events(&plan.session_id.0).await {
Ok(events) => events,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
loop {
tokio::select! {
evt = events.recv() => {
if let Ok(evt) = evt {
if opts.emit_events {
emit_progress(&opts, &evt.session_id.0, evt.sequence, &evt.stage, evt.progress, &evt.message, evt.timestamp.to_rfc3339());
}
if evt.terminal {
break;
}
}
}
_ = sleep(Duration::from_millis(10)) => {
let report = match core.firmware_report(&plan.session_id.0).await {
Ok(report) => report,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path)
.await;
return Err(err.into());
}
};
if let Some(report) = report {
emit_final(&opts, &report);
maybe_persist_report(&core, &opts, &report).await;
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Ok(report);
}
}
}
}
let report = match core.firmware_report(&plan.session_id.0).await {
Ok(report) => report.unwrap_or(FirmwareFinalReport {
session_id: plan.session_id,
status: FirmwareOutcome::Failed,
started_at: None,
completed_at: None,
bytes_total: 0,
chunks_total: 0,
chunks_sent: 0,
error_code: None,
message: "missing final report".to_owned(),
}),
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
emit_final(&opts, &report);
maybe_persist_report(&core, &opts, &report).await;
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
Ok(report)
}
pub async fn cancel_headless(
core: &OpenBitdoCore,
session_id: &str,
) -> Result<FirmwareFinalReport> {
core.cancel_firmware(FirmwareCancelRequest {
session_id: bitdo_app_core::FirmwareUpdateSessionId(session_id.to_owned()),
})
.await
.map_err(Into::into)
}
fn emit_progress(
opts: &RunLaunchOptions,
session_id: &str,
sequence: u64,
stage: &str,
progress: u8,
message: &str,
timestamp: String,
) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!("[{progress:>3}%] {stage}: {message}");
}
HeadlessOutputMode::Json => {
let payload = JsonProgress {
r#type: "progress",
session_id,
sequence,
stage,
progress,
message,
timestamp,
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
fn emit_final(opts: &RunLaunchOptions, report: &FirmwareFinalReport) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!(
"final: {:?} chunks={}/{} message={}",
report.status, report.chunks_sent, report.chunks_total, report.message
);
}
HeadlessOutputMode::Json => {
let payload = JsonFinal {
r#type: "final",
session_id: &report.session_id.0,
status: match report.status {
FirmwareOutcome::Completed => "Completed",
FirmwareOutcome::Cancelled => "Cancelled",
FirmwareOutcome::Failed => "Failed",
},
chunks_sent: report.chunks_sent,
chunks_total: report.chunks_total,
message: &report.message,
error_code: report.error_code.map(|err| format!("{err:?}")),
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
fn emit_failed_final(opts: &RunLaunchOptions, session_id: &str, message: &str) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!("final: Failed message={message}");
}
HeadlessOutputMode::Json => {
let payload = JsonFinal {
r#type: "final",
session_id,
status: "Failed",
chunks_sent: 0,
chunks_total: 0,
message,
error_code: None,
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
async fn maybe_persist_report(
core: &OpenBitdoCore,
opts: &RunLaunchOptions,
report: &FirmwareFinalReport,
) {
let is_failure = report.status != FirmwareOutcome::Completed;
if !should_save_support_report(opts.report_save_mode, is_failure) {
return;
}
let devices = core.list_devices().await.unwrap_or_default();
let selected = devices.iter().find(|d| d.vid_pid == opts.vid_pid);
let status = if is_failure { "failed" } else { "completed" };
let _ = persist_support_report(
"fw-write",
selected,
status,
report.message.clone(),
None,
Some(report),
)
.await;
}
async fn maybe_cleanup_downloaded_firmware(downloaded_firmware: bool, firmware_path: &Path) {
if downloaded_firmware {
let _ = cleanup_temp_file(firmware_path).await;
}
}
async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
pub mod ui_state;

View File

@@ -0,0 +1,82 @@
use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PersistedUiState {
#[serde(default = "default_settings_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub advanced_mode: bool,
#[serde(default)]
pub report_save_mode: ReportSaveMode,
#[serde(default)]
pub device_filter_text: String,
#[serde(default)]
pub dashboard_layout_mode: DashboardLayoutMode,
#[serde(default)]
pub last_panel_focus: PanelFocus,
}
impl Default for PersistedUiState {
fn default() -> Self {
Self {
schema_version: default_settings_schema_version(),
advanced_mode: false,
report_save_mode: ReportSaveMode::FailureOnly,
device_filter_text: String::new(),
dashboard_layout_mode: DashboardLayoutMode::Wide,
last_panel_focus: PanelFocus::Devices,
}
}
}
const fn default_settings_schema_version() -> u32 {
2
}
pub fn load_ui_state(path: &Path) -> Result<PersistedUiState> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(PersistedUiState::default())
}
Err(err) => return Err(err.into()),
};
let mut loaded: PersistedUiState = toml::from_str(&raw)
.map_err(|err| anyhow!("failed to parse ui state {}: {err}", path.display()))?;
loaded.schema_version = default_settings_schema_version();
if !loaded.advanced_mode && loaded.report_save_mode == ReportSaveMode::Off {
loaded.report_save_mode = ReportSaveMode::FailureOnly;
}
Ok(loaded)
}
pub fn persist_ui_state(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
device_filter_text: String,
dashboard_layout_mode: DashboardLayoutMode,
last_panel_focus: PanelFocus,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedUiState {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
})
.map_err(|err| anyhow!("failed to serialize ui state: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,314 @@
use crate::app::effect::{Effect, MappingApplyDraft};
use crate::app::event::AppEvent;
use crate::app::state::AppState;
use crate::persistence::ui_state::persist_ui_state;
use crate::support_report::persist_support_report;
use bitdo_app_core::{
FirmwareCancelRequest, FirmwareConfirmRequest, FirmwarePreflightRequest, FirmwareStartRequest,
OpenBitdoCore, U2SlotId,
};
use std::path::Path;
pub async fn execute_effect(
core: &OpenBitdoCore,
state: &AppState,
effect: Effect,
) -> Vec<AppEvent> {
match effect {
Effect::RefreshDevices => match core.list_devices().await {
Ok(mut devices) => {
devices.sort_by_key(|d| (d.vid_pid.vid, d.vid_pid.pid));
vec![AppEvent::DevicesLoaded(devices)]
}
Err(err) => vec![AppEvent::DevicesLoadFailed(err.to_string())],
},
Effect::RunDiagnostics { vid_pid } => match core.diag_probe(vid_pid).await {
Ok(result) => {
let summary = state
.devices
.iter()
.find(|device| device.vid_pid == vid_pid)
.map(|device| core.beginner_diag_summary(device, &result))
.unwrap_or_else(|| "Diagnostics completed".to_owned());
vec![AppEvent::DiagnosticsCompleted {
vid_pid,
result,
summary,
}]
}
Err(err) => vec![AppEvent::DiagnosticsFailed {
vid_pid,
error: err.to_string(),
}],
},
Effect::LoadMappings { vid_pid } => {
let device = state.devices.iter().find(|d| d.vid_pid == vid_pid);
if let Some(device) = device {
if device.capability.supports_jp108_dedicated_map {
match core.jp108_read_dedicated_mapping(vid_pid).await {
Ok(mappings) => vec![AppEvent::MappingsLoadedJp108 { vid_pid, mappings }],
Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())],
}
} else if device.capability.supports_u2_button_map
&& device.capability.supports_u2_slot_config
{
match core.u2_read_core_profile(vid_pid, U2SlotId::Slot1).await {
Ok(profile) => vec![AppEvent::MappingsLoadedUltimate2 { vid_pid, profile }],
Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())],
}
} else {
vec![AppEvent::MappingLoadFailed(
"Device does not support mapping editor".to_owned(),
)]
}
} else {
vec![AppEvent::MappingLoadFailed("No device selected".to_owned())]
}
}
Effect::ApplyMappings { vid_pid, draft } => match draft {
MappingApplyDraft::Jp108(mappings) => match core
.jp108_apply_dedicated_mapping_with_recovery(vid_pid, mappings, true)
.await
{
Ok(report) => {
let recovery_lock = report.rollback_failed();
let message = if report.write_applied {
"JP108 mapping applied".to_owned()
} else if recovery_lock {
"Apply failed and rollback failed; writes locked until restart".to_owned()
} else {
"Apply failed but rollback restored prior mapping".to_owned()
};
vec![AppEvent::MappingApplied {
backup_id: report.backup_id,
message,
recovery_lock,
}]
}
Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())],
},
MappingApplyDraft::Ultimate2(profile) => match core
.u2_apply_core_profile_with_recovery(
vid_pid,
profile.slot,
profile.mode,
profile.mappings,
profile.l2_analog,
profile.r2_analog,
true,
)
.await
{
Ok(report) => {
let recovery_lock = report.rollback_failed();
let message = if report.write_applied {
"Ultimate2 profile applied".to_owned()
} else if recovery_lock {
"Apply failed and rollback failed; writes locked until restart".to_owned()
} else {
"Apply failed but rollback restored prior profile".to_owned()
};
vec![AppEvent::MappingApplied {
backup_id: report.backup_id,
message,
recovery_lock,
}]
}
Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())],
},
},
Effect::RestoreBackup { backup_id } => match core.restore_backup(backup_id).await {
Ok(_) => vec![AppEvent::BackupRestoreCompleted(
"Backup restore completed".to_owned(),
)],
Err(err) => vec![AppEvent::BackupRestoreFailed(format!(
"Backup restore failed: {err}"
))],
},
Effect::PreparePreflight {
vid_pid,
firmware_path_override,
allow_unsafe,
brick_risk_ack,
experimental,
chunk_size,
} => {
let device = state.devices.iter().find(|d| d.vid_pid == vid_pid);
let Some(device) = device else {
return vec![AppEvent::PreflightBlocked("No selected device".to_owned())];
};
let (firmware_path, source, version, downloaded_firmware_path) =
if let Some(path) = firmware_path_override {
(path, "local file".to_owned(), "manual".to_owned(), None)
} else {
match core.download_recommended_firmware(vid_pid).await {
Ok(download) => {
let path = download.firmware_path;
(
path.clone(),
"recommended verified download".to_owned(),
download.version,
Some(path),
)
}
Err(err) => {
return vec![AppEvent::PreflightBlocked(format!(
"Recommended firmware unavailable: {err}"
))]
}
}
};
match core
.preflight_firmware(FirmwarePreflightRequest {
vid_pid: device.vid_pid,
firmware_path: firmware_path.clone(),
allow_unsafe,
brick_risk_ack,
experimental,
chunk_size,
})
.await
{
Ok(preflight) => {
if !preflight.gate.allowed {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(
preflight
.gate
.message
.unwrap_or_else(|| "Preflight denied by policy".to_owned()),
)]
} else if let Some(plan) = preflight.plan {
vec![AppEvent::PreflightReady {
vid_pid,
firmware_path,
source,
version,
plan,
downloaded_firmware_path,
}]
} else {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(
"Preflight allowed but no transfer plan was returned".to_owned(),
)]
}
}
Err(err) => {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(format!(
"Preflight failed: {err}"
))]
}
}
}
Effect::StartFirmware {
session_id,
acknowledged_risk,
} => {
if let Err(err) = core
.start_firmware(FirmwareStartRequest {
session_id: session_id.clone(),
})
.await
{
return vec![AppEvent::UpdateFailed(err.to_string())];
}
if let Err(err) = core
.confirm_firmware(FirmwareConfirmRequest {
session_id: session_id.clone(),
acknowledged_risk,
})
.await
{
return vec![AppEvent::UpdateFailed(err.to_string())];
}
vec![AppEvent::UpdateStarted {
session_id: session_id.0,
source: "selected firmware".to_owned(),
version: "target".to_owned(),
}]
}
Effect::CancelFirmware { session_id } => match core
.cancel_firmware(FirmwareCancelRequest { session_id })
.await
{
Ok(report) => vec![AppEvent::UpdateFinished(report)],
Err(err) => vec![AppEvent::UpdateFailed(err.to_string())],
},
Effect::PollFirmwareReport { session_id } => {
match core.firmware_report(&session_id.0).await {
Ok(Some(report)) => vec![AppEvent::UpdateFinished(report)],
Ok(None) => Vec::new(),
Err(err) => vec![AppEvent::UpdateFailed(err.to_string())],
}
}
Effect::DeleteTempFile { path } => match cleanup_temp_file(&path).await {
Ok(_) => Vec::new(),
Err(err) => vec![AppEvent::Error(format!(
"Failed to delete temporary firmware {}: {err}",
path.display()
))],
},
Effect::PersistSettings {
path,
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
} => match persist_ui_state(
&path,
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
) {
Ok(_) => vec![AppEvent::SettingsPersisted],
Err(err) => vec![AppEvent::Error(format!("Settings save failed: {err}"))],
},
Effect::PersistSupportReport {
operation,
vid_pid,
status,
message,
diag,
firmware,
} => {
let device = vid_pid.and_then(|id| state.devices.iter().find(|d| d.vid_pid == id));
match persist_support_report(
&operation,
device,
&status,
message,
diag.as_ref(),
firmware.as_ref(),
)
.await
{
Ok(path) => vec![AppEvent::SupportReportSaved(path)],
Err(err) => vec![AppEvent::Error(format!(
"Support report save failed: {err}"
))],
}
}
}
}
async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}

View File

@@ -0,0 +1,281 @@
use super::effect_executor::execute_effect;
use crate::app::event::AppEvent;
use crate::app::reducer::reduce;
use crate::app::state::{AppState, EventLevel, Screen, TaskMode};
use crate::persistence::ui_state::load_ui_state;
use crate::support_report::prune_reports_on_startup;
use crate::ui::layout::{self, HitTarget};
use crate::UiLaunchOptions;
use anyhow::Result;
use bitdo_app_core::{FirmwareProgressEvent, OpenBitdoCore};
use crossterm::event::{self, Event as CEvent, KeyCode, MouseButton, MouseEvent, MouseEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::collections::VecDeque;
use std::io::Stdout;
use tokio::sync::broadcast;
use tokio::time::Duration;
pub async fn run_ui_loop(core: OpenBitdoCore, opts: UiLaunchOptions) -> Result<()> {
let _ = prune_reports_on_startup().await;
let mut state = AppState::new(&opts);
if let Some(path) = state.settings_path.as_ref() {
match load_ui_state(path) {
Ok(persisted) => {
state.device_filter = persisted.device_filter_text;
state.dashboard_layout_mode = persisted.dashboard_layout_mode;
state.last_panel_focus = persisted.last_panel_focus;
state.advanced_mode = persisted.advanced_mode;
state.report_save_mode = persisted.report_save_mode;
}
Err(err) => {
state.set_status("Settings file invalid; using defaults");
state.append_event(EventLevel::Warning, format!("Settings load failed: {err}"));
}
}
}
let mut terminal = init_terminal()?;
let mut hit_map = layout::HitMap::default();
let mut firmware_events: Option<broadcast::Receiver<FirmwareProgressEvent>> = None;
process_event(&core, &mut state, AppEvent::Init).await;
loop {
if let Ok(size) = terminal.size() {
state.set_layout_from_size(size.width, size.height);
}
ensure_firmware_subscription(&core, &state, &mut firmware_events).await?;
poll_firmware_events(&mut state, &mut firmware_events).await;
process_event(&core, &mut state, AppEvent::Tick).await;
terminal.draw(|frame| {
hit_map = layout::render(frame, &state);
})?;
if state.quit_requested {
break;
}
if !event::poll(Duration::from_millis(80))? {
continue;
}
match event::read()? {
CEvent::Key(key) => {
if let Some(app_event) = key_to_event(&state, key.code) {
process_event(&core, &mut state, app_event).await;
}
}
CEvent::Mouse(mouse) => {
if let Some(app_event) = mouse_to_event(&state, &hit_map, mouse) {
process_event(&core, &mut state, app_event).await;
}
}
CEvent::Resize(width, height) => {
state.set_layout_from_size(width, height);
}
_ => {}
}
}
teardown_terminal(&mut terminal)?;
Ok(())
}
async fn process_event(core: &OpenBitdoCore, state: &mut AppState, initial: AppEvent) {
let mut queue = VecDeque::from([initial]);
while let Some(event) = queue.pop_front() {
let effects = reduce(state, event);
for effect in effects {
let emitted = execute_effect(core, state, effect).await;
for next in emitted {
queue.push_back(next);
}
}
}
}
async fn ensure_firmware_subscription(
core: &OpenBitdoCore,
state: &AppState,
receiver: &mut Option<broadcast::Receiver<FirmwareProgressEvent>>,
) -> Result<()> {
if receiver.is_some() {
return Ok(());
}
let Some(task) = state.task_state.as_ref() else {
return Ok(());
};
if !matches!(task.mode, TaskMode::Updating) {
return Ok(());
}
let Some(plan) = task.plan.as_ref() else {
return Ok(());
};
*receiver = Some(core.subscribe_events(&plan.session_id.0).await?);
Ok(())
}
async fn poll_firmware_events(
state: &mut AppState,
receiver: &mut Option<broadcast::Receiver<FirmwareProgressEvent>>,
) {
let Some(rx) = receiver.as_mut() else {
return;
};
let mut events = Vec::new();
loop {
match rx.try_recv() {
Ok(evt) => events.push(evt),
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => {
*receiver = None;
break;
}
}
}
for evt in events {
let _ = reduce(state, AppEvent::UpdateProgress(evt));
}
}
fn key_to_event(state: &AppState, key: KeyCode) -> Option<AppEvent> {
match key {
KeyCode::Char('q') => Some(AppEvent::Quit),
KeyCode::Esc => Some(AppEvent::Back),
KeyCode::Enter => Some(AppEvent::ConfirmPrimary),
KeyCode::Down => match state.screen {
Screen::Dashboard => Some(AppEvent::SelectNextDevice),
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck),
Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(1)),
_ => Some(AppEvent::SelectNextAction),
},
KeyCode::Up => match state.screen {
Screen::Dashboard => Some(AppEvent::SelectPrevDevice),
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck),
Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(-1)),
_ => Some(AppEvent::SelectPrevAction),
},
KeyCode::Left => match state.screen {
Screen::MappingEditor => Some(AppEvent::MappingAdjust(-1)),
Screen::Diagnostics => Some(AppEvent::SelectPrevAction),
_ => Some(AppEvent::SelectPrevAction),
},
KeyCode::Right => match state.screen {
Screen::MappingEditor => Some(AppEvent::MappingAdjust(1)),
Screen::Diagnostics => Some(AppEvent::SelectNextAction),
_ => Some(AppEvent::SelectNextAction),
},
KeyCode::Tab => {
if state.screen == Screen::Diagnostics {
Some(AppEvent::DiagnosticsShiftFilter(1))
} else {
None
}
}
KeyCode::BackTab => {
if state.screen == Screen::Diagnostics {
Some(AppEvent::DiagnosticsShiftFilter(-1))
} else {
None
}
}
KeyCode::Backspace => {
if state.screen == Screen::Dashboard {
Some(AppEvent::DeviceFilterBackspace)
} else {
None
}
}
KeyCode::Char('t') => {
if state.screen == Screen::Settings {
Some(AppEvent::ToggleAdvancedMode)
} else {
None
}
}
KeyCode::Char('r') => {
if state.screen == Screen::Settings {
Some(AppEvent::CycleReportSaveMode)
} else {
None
}
}
KeyCode::Char(ch) => {
if state.screen == Screen::Dashboard && !ch.is_control() {
Some(AppEvent::DeviceFilterInput(ch))
} else {
None
}
}
_ => None,
}
}
fn mouse_to_event(
state: &AppState,
hit_map: &layout::HitMap,
mouse: MouseEvent,
) -> Option<AppEvent> {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => match hit_map.hit(mouse.column, mouse.row) {
Some(HitTarget::DeviceRow(idx)) => Some(AppEvent::SelectFilteredDevice(idx)),
Some(HitTarget::QuickAction(action)) => Some(AppEvent::TriggerAction(action)),
Some(HitTarget::FilterInput) => Some(AppEvent::SelectFilteredDevice(0)),
Some(HitTarget::DiagnosticsCheck(idx)) => Some(AppEvent::DiagnosticsSelectCheck(idx)),
Some(HitTarget::DiagnosticsFilter(filter)) => {
Some(AppEvent::DiagnosticsSetFilter(filter))
}
Some(HitTarget::ToggleAdvancedMode) => Some(AppEvent::ToggleAdvancedMode),
Some(HitTarget::CycleReportMode) => Some(AppEvent::CycleReportSaveMode),
None => None,
},
MouseEventKind::ScrollDown => match state.screen {
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck),
_ => Some(AppEvent::SelectNextDevice),
},
MouseEventKind::ScrollUp => match state.screen {
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck),
_ => Some(AppEvent::SelectPrevDevice),
},
_ => None,
}
}
fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
use crossterm::event::EnableMouseCapture;
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
use crossterm::event::DisableMouseCapture;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}

View File

@@ -0,0 +1,2 @@
pub mod effect_executor;
pub mod r#loop;

View File

@@ -1,38 +0,0 @@
use crate::ReportSaveMode;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedSettings {
#[serde(default = "default_settings_schema_version")]
schema_version: u32,
#[serde(default)]
advanced_mode: bool,
#[serde(default)]
report_save_mode: ReportSaveMode,
}
const fn default_settings_schema_version() -> u32 {
1
}
/// Persist beginner/advanced preferences in a small TOML config file.
pub(crate) fn persist_user_settings(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedSettings {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
})
.map_err(|err| anyhow!("failed to serialize user settings: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 196
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Dashboard • 3 devices • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Search filter────────────────╮╭Device full──────────╮╭Actions Enter/click──╮
│Search active ││Ultimate2 2dc8:5209 ││› Refresh • scan │
╰──────────────────────────────╯│Support: supported ││ Diagnose • probe │
╭Controllers detected─────────╮│Protocol: Standard64 ││ Recommended Update │
│› 2dc8:5209 Ultimate2 ││Evidence: Confirmed ││ Edit Mapping • map│
│full • S64 • conf ││ ││ Settings • prefs │
│ 2dc8:6009 Ultimate ││Capabilities ││ Quit • exit │
│ro • S64 • infer ││• firmware ││ │
│ 2dc8:901a Candidate ││• profile rw │╰──────────────────────╯
│ro • unknown • untest ││• mode switch │╭Activity events──────╮
│ ││• JP108 mapping ││ │
│ ││• U2 slot + map ││ │
│ ││ ││ │
│ ││ ││ │
│ ││ ││ │
│ ││ ││ │
╰──────────────────────────────╯╰──────────────────────╯╰──────────────────────╯
╭Status────────────────────────────────────────────────────────────────────────╮
│Ready │
│Ultimate2 • click • arrows • Enter • Esc/q │
╰──────────────────────────────────────────────────────────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 317
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮
│All ││Issues ││Experimental ││GetPid OK │
╰─────────────╯╰────────────╯╰─────────────╯│Severity: Ok │
╭Checks click a row───────────────────────╮│Confidence: Confirmed │
│› OK GetPid detected pid 0x5… ││Experimental: no │
│ OK GetMode mode 2 ││Error code: none │
│ OK GetSuperButton exp ok ││Response: Ok • attempts 1 │
│ WARN ReadProfile exp timeout while wa… ││IO: wrote 64B, read 64B │
│ ATTN Version invalid response… ││Validator: test:GetPid │
│ │╰──────────────────────────────────────────────────────╯
│ │╭Next Steps guidance──────────────────────────────────╮
│ ││Action: Return to the dashboard and choose Recomme… │
│ ││Summary: 3/5 checks passed. Experimental checks: 1… │
│ ││Saved report: not yet saved in this screen │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯
╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 327
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Checks tab cycles filter─────────────────────────────────────────────────────╮
│All 5 Issues 2 Exp 2 │
│› OK GetPid detected pid 0x5209 │
│ OK GetMode mode 2 │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Selected Check detail────────────────────────────────────────────────────────╮
│GetPid OK │
│Severity: Ok │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Next Steps guidance──────────────────────────────────────────────────────────╮
│Action: Return to the dashboard and choose Recommended Update or Edit Mapp… │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Run Again───────────────╮╭Save Report──────────────╮╭Back────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 343
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮
│All ││Issues ││Experimental ││Version ATTN │
╰─────────────╯╰────────────╯╰─────────────╯│Severity: NeedsAttention │
╭Checks click a row───────────────────────╮│Confidence: Confirmed │
│ WARN ReadProfile exp timeout while wa… ││Experimental: no │
│› ATTN Version invalid response… ││Error code: InvalidResponse │
│ ││Response: Invalid • attempts 1 │
│ ││IO: wrote 64B, read 8B │
│ ││Validator: test:Version │
│ │╰──────────────────────────────────────────────────────╯
│ │╭Next Steps guidance──────────────────────────────────╮
│ ││Action: Return to the dashboard and choose Recomme… │
│ ││Summary: 3/5 checks passed. Experimental checks: 1… │
│ ││Saved report: /tmp/openbitdo-diag-report.toml │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯
╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 233
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Mappings • draft modified • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Mapping Studio modified──────────────────────────────────────────────────────────────────────────╮
│Apply is explicit. Arrow keys adjust only the highlighted mapping. │
│ │
│JP108 dedicated mapping │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Mappings up/down select • left/right adjust────────────────╮╭Inspector selected mapping─────────╮
│› A -> 0x0005 ││Button: A │
│ ││Target HID: 0x0005 │
│ ││ │
│ ││Left/right cycles preset targets. │
│ ││ │
│ ││Ready │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────────────────────────────╯╰────────────────────────────────────╯
╭Apply──────────────────────────╮╭Undo───────────────────────────╮╭Reset──────────────────────────╮
│Apply ││Undo ││Reset │
│write current draft ││restore last edit ││discard draft changes │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
╭Restore Backup─────────────────╮╭Firmware───────────────────────╮╭Back───────────────────────────╮
│Restore Backup ││Firmware ││Back │
│recover saved backup ││switch to firmware flow ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 243
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Recovery • write lock active • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Recovery safe rollback path──────────────────────────────────────────────────╮
│Recovery lock is active │
│ │
│Write operations stay blocked until the app restarts. │
│Restore a backup if one exists, validate the device, then restart. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Guidance recommended sequence────────────────────────────────────────────────╮
│1. Restore backup if available. │
│2. Confirm the controller responds normally. │
│3. Restart OpenBitDo before any further writes. │
│ │
│No backup is registered for this session. │
│Ready │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Restore Backup──────────╮╭Back─────────────────────╮╭Quit────────────────────╮
│Restore Backup ││Back ││Quit │
│attempt rollback ││return to dashboard ││exit openbitdo │
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 212
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Workflow • Ready • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Preflight status and intent──────────────────────────────────────────────────────────────────────╮
│Preflight Workflow preflight safety check │
│ │
│Ready to confirm transfer │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Details workflow context───────────────────────────────╮╭Progress transfer state────────────────╮
│Ready to confirm transfer ││█████ │
│ ││█████ 12% │
│ │╰────────────────────────────────────────╯
│ │╭Context current session────────────────╮
│ ││Stage: preflight safety check │
│ ││Progress: 12% │
│ ││Reports: failure_only │
│ ││Ready │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────────────────────────╯╰────────────────────────────────────────╯
╭Confirm────────────────────────╮╭Cancel─────────────────────────╮╭Back───────────────────────────╮
│Confirm ││Cancel ││Back │
│acknowledge risk + start ││stop this workflow ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -1,370 +1,654 @@
use super::*;
use crate::support_report::report_subject_token;
use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig};
use bitdo_proto::SupportLevel;
#[test]
fn about_state_roundtrip_returns_home() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("SERIAL1".to_owned()),
connected: true,
}]);
app.open_about();
assert_eq!(app.state, TuiWorkflowState::About);
app.close_overlay();
assert_eq!(app.state, TuiWorkflowState::Home);
}
#[test]
fn refresh_devices_without_any_device_enters_wait_state() {
let mut app = TuiApp::default();
app.refresh_devices(Vec::new());
assert_eq!(app.state, TuiWorkflowState::WaitForDevice);
assert!(app.selected.is_none());
}
#[test]
fn refresh_devices_autoselects_single_device() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "One".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
}]);
assert_eq!(app.state, TuiWorkflowState::Home);
assert_eq!(app.selected_index, 0);
assert_eq!(app.selected, Some(VidPid::new(0x2dc8, 0x6009)));
}
#[test]
fn serial_token_prefers_serial_then_vidpid() {
let with_serial = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "S".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("ABC 123".to_owned()),
connected: true,
use crate::app::action::QuickAction;
use crate::app::event::AppEvent;
use crate::app::reducer::reduce;
use crate::app::state::{
AppState, DiagnosticsFilter, DiagnosticsState, MappingDraftState, Screen, TaskMode,
};
assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123");
let without_serial = AppDevice {
serial: None,
..with_serial
use crate::persistence::ui_state::{load_ui_state, persist_ui_state};
use crate::runtime::effect_executor::execute_effect;
use bitdo_app_core::{DedicatedButtonId, DedicatedButtonMapping, OpenBitdoCoreConfig};
use bitdo_proto::{
BitdoErrorCode, CommandId, DiagCommandStatus, DiagProbeResult, DiagSeverity,
EvidenceConfidence, ResponseStatus, SupportTier, VidPid,
};
assert_eq!(report_subject_token(Some(&without_serial)), "2dc86009");
use insta::assert_snapshot;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[tokio::test]
async fn quick_action_matrix_blocks_update_for_read_only() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = AppState::new(&UiLaunchOptions::default());
let devices = core.list_devices().await.expect("devices");
let _ = reduce(&mut state, AppEvent::DevicesLoaded(devices));
let update = state
.quick_actions
.iter()
.find(|a| a.action == QuickAction::RecommendedUpdate)
.expect("update action");
assert!(update.enabled);
let readonly_idx = state
.devices
.iter()
.position(|d| d.support_tier != SupportTier::Full)
.expect("readonly device");
state.selected_device_id = Some(state.devices[readonly_idx].vid_pid);
state.recompute_quick_actions();
let update = state
.quick_actions
.iter()
.find(|a| a.action == QuickAction::RecommendedUpdate)
.expect("update action");
assert!(!update.enabled);
}
#[test]
fn launch_options_default_to_failure_only_reports() {
let opts = TuiLaunchOptions::default();
assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly);
fn mapping_draft_undo_and_reset() {
let mut state = AppState::new(&UiLaunchOptions::default());
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
current: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
undo_stack: Vec::new(),
selected_row: 0,
});
let _ = reduce(&mut state, AppEvent::MappingAdjust(1));
assert!(state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::UndoDraft));
assert!(!state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::MappingAdjust(1));
assert!(state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::ResetDraft));
assert!(!state.mapping_has_changes());
}
#[test]
fn blocked_panel_text_matches_support_tier() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x2100),
name: "Candidate".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
fn settings_schema_v2_roundtrip() {
let path = std::env::temp_dir().join("bitdo-tui-ui-state-v2.toml");
persist_ui_state(
&path,
true,
ReportSaveMode::Always,
"ultimate".to_owned(),
DashboardLayoutMode::Compact,
PanelFocus::QuickActions,
)
.expect("persist");
let loaded = load_ui_state(&path).expect("load");
assert_eq!(loaded.schema_version, 2);
assert!(loaded.advanced_mode);
assert_eq!(loaded.report_save_mode, ReportSaveMode::Always);
assert_eq!(loaded.device_filter_text, "ultimate");
assert_eq!(loaded.dashboard_layout_mode, DashboardLayoutMode::Compact);
assert_eq!(loaded.last_panel_focus, PanelFocus::QuickActions);
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_ui_state_returns_error() {
let path = std::env::temp_dir().join("bitdo-tui-invalid-ui-state.toml");
std::fs::write(&path, "advanced_mode = [").expect("write invalid");
let err = load_ui_state(&path).expect_err("invalid ui state must error");
assert!(err.to_string().contains("failed to parse ui state"));
let _ = std::fs::remove_file(path);
}
#[test]
fn launch_defaults_are_safe() {
let ui = UiLaunchOptions::default();
assert!(!ui.allow_unsafe);
assert!(!ui.brick_risk_ack);
let headless = RunLaunchOptions::default();
assert!(!headless.allow_unsafe);
assert!(!headless.brick_risk_ack);
assert!(!headless.acknowledged_risk);
}
#[tokio::test]
async fn integration_refresh_select_preflight_cancel_path() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = AppState::new(&UiLaunchOptions::default());
drive(&core, &mut state, AppEvent::Init).await;
assert!(!state.devices.is_empty());
let full_support_index = state
.devices
.iter()
.position(|device| device.support_tier == SupportTier::Full)
.expect("full-support device");
drive(
&core,
&mut state,
AppEvent::SelectFilteredDevice(full_support_index),
)
.await;
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::RecommendedUpdate),
)
.await;
assert_eq!(state.screen, Screen::Task);
assert!(state.task_state.is_some());
let downloaded_path = state
.task_state
.as_ref()
.and_then(|task| task.downloaded_firmware_path.clone())
.expect("downloaded firmware path");
assert!(downloaded_path.exists());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Cancel),
)
.await;
assert_eq!(state.screen, Screen::Dashboard);
assert!(!downloaded_path.exists());
}
#[tokio::test]
async fn integration_diagnostics_run_rerun_save_and_back() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = AppState::new(&UiLaunchOptions::default());
drive(&core, &mut state, AppEvent::Init).await;
drive(&core, &mut state, AppEvent::SelectFilteredDevice(0)).await;
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Diagnose),
)
.await;
assert_eq!(state.screen, Screen::Diagnostics);
assert!(state.diagnostics_state.is_some());
assert!(state.task_state.is_none());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::RunAgain),
)
.await;
assert_eq!(state.screen, Screen::Diagnostics);
assert!(state.diagnostics_state.is_some());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::SaveReport),
)
.await;
let saved_path = state
.diagnostics_state
.as_ref()
.and_then(|diagnostics| diagnostics.latest_report_path.clone())
.expect("diagnostics report path");
assert!(saved_path.exists());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Back),
)
.await;
assert_eq!(state.screen, Screen::Dashboard);
let _ = std::fs::remove_file(saved_path);
}
#[test]
fn diagnostics_filter_changes_visible_rows() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
assert_eq!(state.diagnostics_filtered_indices(), vec![0, 1, 2, 3, 4]);
let _ = reduce(
&mut state,
AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Issues),
);
assert_eq!(state.diagnostics_filtered_indices(), vec![3, 4]);
assert_eq!(
state
.selected_diagnostics_check()
.map(|check| check.command),
Some(CommandId::ReadProfile)
);
let _ = reduce(
&mut state,
AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Experimental),
);
assert_eq!(state.diagnostics_filtered_indices(), vec![2, 3]);
assert_eq!(
state
.selected_diagnostics_check()
.map(|check| check.command),
Some(CommandId::ReadProfile)
);
}
#[tokio::test]
async fn manual_save_report_updates_diagnostics_state() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::SaveReport),
)
.await;
let saved_path = state
.diagnostics_state
.as_ref()
.and_then(|diagnostics| diagnostics.latest_report_path.clone())
.expect("saved diagnostics report path");
assert!(saved_path.exists());
let _ = std::fs::remove_file(saved_path);
}
#[test]
fn recovery_transition_is_preserved() {
let mut state = AppState::new(&UiLaunchOptions::default());
let _ = reduce(
&mut state,
AppEvent::MappingApplied {
backup_id: None,
message: "rollback failed".to_owned(),
recovery_lock: true,
},
);
assert_eq!(state.screen, Screen::Recovery);
assert!(state.write_lock_until_restart);
}
#[tokio::test]
async fn headless_human_and_json_modes_complete() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
progress_interval_ms: 1,
..Default::default()
});
let report_human = run_headless(
core.clone(),
RunLaunchOptions {
vid_pid: VidPid::new(0x2dc8, 0x6009),
use_recommended: true,
allow_unsafe: true,
brick_risk_ack: true,
acknowledged_risk: true,
output_mode: HeadlessOutputMode::Human,
emit_events: false,
..Default::default()
},
)
.await
.expect("human mode");
assert_eq!(
report_human.status,
bitdo_app_core::FirmwareOutcome::Completed
);
let report_json = run_headless(
core,
RunLaunchOptions {
vid_pid: VidPid::new(0x2dc8, 0x6009),
use_recommended: true,
allow_unsafe: true,
brick_risk_ack: true,
acknowledged_risk: true,
output_mode: HeadlessOutputMode::Json,
emit_events: true,
..Default::default()
},
)
.await
.expect("json mode");
assert_eq!(
report_json.status,
bitdo_app_core::FirmwareOutcome::Completed
);
}
#[test]
fn snapshot_dashboard_80x24() {
let mut state = snapshot_state();
state.dashboard_layout_mode = DashboardLayoutMode::Wide;
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_task_screen_100x30() {
let mut state = snapshot_state();
state.screen = Screen::Task;
state.task_state = Some(crate::app::state::TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 12,
status: "Ready to confirm transfer".to_owned(),
final_report: None,
downloaded_firmware_path: None,
});
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_100x30() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_80x24() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_with_saved_report() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(Some(PathBuf::from(
"/tmp/openbitdo-diag-report.toml",
))));
if let Some(diagnostics) = state.diagnostics_state.as_mut() {
diagnostics.active_filter = DiagnosticsFilter::Issues;
diagnostics.selected_check_index = 4;
}
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_mapping_editor_screen() {
let mut state = snapshot_state();
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
current: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0005,
}],
undo_stack: Vec::new(),
selected_row: 0,
});
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_recovery_screen() {
let mut state = snapshot_state();
state.screen = Screen::Recovery;
state.write_lock_until_restart = true;
state.recompute_quick_actions();
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
async fn drive(core: &bitdo_app_core::OpenBitdoCore, state: &mut AppState, initial: AppEvent) {
let mut queue = std::collections::VecDeque::from([initial]);
while let Some(event) = queue.pop_front() {
let effects = reduce(state, event);
for effect in effects {
let emitted = execute_effect(core, state, effect).await;
for next in emitted {
queue.push_back(next);
}
}
}
}
fn snapshot_state() -> AppState {
let mut state = AppState::new(&UiLaunchOptions::default());
let _ = reduce(
&mut state,
AppEvent::DevicesLoaded(vec![
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x5209),
name: "Ultimate2".to_owned(),
support_level: bitdo_proto::SupportLevel::Full,
support_tier: bitdo_proto::SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
},
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Ultimate".to_owned(),
support_level: bitdo_proto::SupportLevel::DetectOnly,
support_tier: bitdo_proto::SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Inferred,
serial: None,
connected: true,
}]);
let selected = app.selected_device().expect("selected");
let text = blocked_action_panel_text(selected);
assert!(text.contains("blocked"));
assert!(text.contains("Status shown as Blocked"));
assert_eq!(beginner_status_label(selected), "Blocked");
}
#[test]
fn non_advanced_report_mode_skips_off_setting() {
let mut app = TuiApp {
advanced_mode: false,
..Default::default()
};
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::Always);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn unknown_device_label_is_beginner_friendly() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0xabcd),
name: "PID_UNKNOWN".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::DetectOnly,
},
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x901a),
name: "Candidate".to_owned(),
support_level: bitdo_proto::SupportLevel::DetectOnly,
support_tier: bitdo_proto::SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Untested,
serial: None,
connected: true,
};
let label = super::display_device_name(&device);
assert!(label.contains("Unknown 8BitDo Device"));
assert!(label.contains("2dc8:abcd"));
}
#[tokio::test]
async fn home_refresh_loads_devices() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
assert!(!app.devices.is_empty());
assert!(app.selected_device().is_some());
}
#[tokio::test]
async fn run_tui_app_no_ui_blocks_detect_only_pid() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let result = run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x2100)),
..Default::default()
},
)
.await;
assert!(result.is_err());
]),
);
state.event_log.clear();
state.status_line = "Ready".to_owned();
state
}
#[tokio::test]
async fn run_tui_app_no_ui_full_support_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)),
..Default::default()
},
)
.await
.expect("run app");
fn sample_diagnostics_state(report_path: Option<PathBuf>) -> DiagnosticsState {
DiagnosticsState {
result: sample_diagnostics_result(),
summary: "3/5 checks passed. Experimental checks: 1/2 passed. Issues: 2 total, 1 need attention. Transport ready: yes. Standard64 diagnostics are available. This device is full-support.".to_owned(),
selected_check_index: 0,
active_filter: DiagnosticsFilter::All,
latest_report_path: report_path,
}
}
#[tokio::test]
async fn tui_flow_with_manual_path_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let path = std::env::temp_dir().join("openbitdo-tui-flow.bin");
tokio::fs::write(&path, vec![1u8; 128])
.await
.expect("write");
let report = run_tui_flow(
core,
TuiRunRequest {
vid_pid: VidPid::new(0x2dc8, 0x6009),
firmware_path: path.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: true,
chunk_size: Some(32),
acknowledged_risk: true,
no_ui: true,
},
)
.await
.expect("run tui flow");
assert_eq!(report.status, FirmwareOutcome::Completed);
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn support_report_is_toml_file() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
fn sample_diagnostics_result() -> DiagProbeResult {
DiagProbeResult {
target: VidPid::new(0x2dc8, 0x5209),
profile_name: "Ultimate2".to_owned(),
support_level: bitdo_proto::SupportLevel::Full,
support_tier: bitdo_proto::SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("RPT-1".to_owned()),
connected: true,
};
let report_path = persist_support_report(
"diag-probe",
Some(&device),
"ok",
"all checks passed".to_owned(),
None,
None,
)
.await
.expect("report path");
assert_eq!(
report_path.extension().and_then(|s| s.to_str()),
Some("toml")
);
let _ = tokio::fs::remove_file(report_path).await;
transport_ready: true,
command_checks: vec![
diag_check(
CommandId::GetPid,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::Ok,
error_code: None,
detail: "detected pid 0x5209",
parsed_facts: [("detected_pid", 0x5209)].into_iter().collect(),
},
),
diag_check(
CommandId::GetMode,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::Ok,
error_code: None,
detail: "mode 2",
parsed_facts: [("mode", 2)].into_iter().collect(),
},
),
diag_check(
CommandId::GetSuperButton,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Inferred,
is_experimental: true,
severity: DiagSeverity::Ok,
error_code: None,
detail: "ok",
parsed_facts: BTreeMap::new(),
},
),
diag_check(
CommandId::ReadProfile,
DiagCheckFixture {
ok: false,
confidence: EvidenceConfidence::Inferred,
is_experimental: true,
severity: DiagSeverity::Warning,
error_code: Some(BitdoErrorCode::Timeout),
detail: "timeout while waiting for device response",
parsed_facts: BTreeMap::new(),
},
),
diag_check(
CommandId::Version,
DiagCheckFixture {
ok: false,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::NeedsAttention,
error_code: Some(BitdoErrorCode::InvalidResponse),
detail: "invalid response for Version: response signature mismatch",
parsed_facts: [("version_x100", 4200), ("beta", 0)].into_iter().collect(),
},
),
],
}
}
#[tokio::test]
async fn update_action_enters_jp108_wizard_for_jp108_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::Jp108Mapping);
assert!(!app.jp108_mappings.is_empty());
struct DiagCheckFixture<'a> {
ok: bool,
confidence: EvidenceConfidence,
is_experimental: bool,
severity: DiagSeverity,
error_code: Option<BitdoErrorCode>,
detail: &'a str,
parsed_facts: BTreeMap<&'a str, u32>,
}
#[tokio::test]
async fn update_action_enters_u2_wizard_for_ultimate2_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let u2_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x6012)
.expect("u2 fixture");
app.select_index(u2_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::U2CoreProfile);
assert!(app.u2_profile.is_some());
fn diag_check(command: CommandId, fixture: DiagCheckFixture<'_>) -> DiagCommandStatus {
DiagCommandStatus {
command,
ok: fixture.ok,
confidence: fixture.confidence,
is_experimental: fixture.is_experimental,
severity: fixture.severity,
attempts: 1,
validator: format!("test:{command:?}"),
response_status: if fixture.ok {
ResponseStatus::Ok
} else {
ResponseStatus::Invalid
},
bytes_written: 64,
bytes_read: if fixture.ok { 64 } else { 8 },
error_code: fixture.error_code,
detail: fixture.detail.to_owned(),
parsed_facts: fixture
.parsed_facts
.into_iter()
.map(|(key, value)| (key.to_owned(), value))
.collect(),
}
}
#[tokio::test]
async fn device_flow_backup_apply_sets_backup_id() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.begin_jp108_mapping(
core.jp108_read_dedicated_mapping(VidPid::new(0x2dc8, 0x5209))
.await
.expect("read"),
);
fn render_state(state: &mut AppState, width: u16, height: u16) -> String {
state.set_layout_from_size(width, height);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| {
let _ = crate::ui::layout::render(frame, state);
})
.expect("draw");
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_device_flow_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
DeviceFlowAction::BackupApply,
)
.await
.expect("apply");
assert!(app.latest_backup.is_some());
let backend = terminal.backend();
let buffer = backend.buffer();
let mut lines = Vec::new();
for y in 0..height {
let mut line = String::new();
for x in 0..width {
line.push_str(buffer[(x, y)].symbol());
}
lines.push(line.trim_end().to_owned());
}
lines.join("\n")
}

View File

@@ -0,0 +1,283 @@
use crate::app::action::QuickAction;
use crate::app::state::{AppState, DiagnosticsFilter, Screen};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::screens::{dashboard, diagnostics, mapping_editor, recovery, settings, task};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HitTarget {
DeviceRow(usize),
QuickAction(QuickAction),
FilterInput,
DiagnosticsCheck(usize),
DiagnosticsFilter(DiagnosticsFilter),
ToggleAdvancedMode,
CycleReportMode,
}
#[derive(Clone, Copy, Debug)]
pub struct HitRegion {
pub rect: Rect,
pub target: HitTarget,
}
#[derive(Clone, Debug, Default)]
pub struct HitMap {
pub regions: Vec<HitRegion>,
}
#[derive(Clone, Debug)]
pub struct ActionDescriptor {
pub action: QuickAction,
pub label: String,
pub caption: String,
pub enabled: bool,
pub active: bool,
}
impl HitMap {
pub fn push(&mut self, rect: Rect, target: HitTarget) {
self.regions.push(HitRegion { rect, target });
}
pub fn extend(&mut self, regions: Vec<HitRegion>) {
self.regions.extend(regions);
}
pub fn hit(&self, x: u16, y: u16) -> Option<HitTarget> {
self.regions
.iter()
.find(|region| point_in_rect(x, y, region.rect))
.map(|region| region.target)
}
}
pub fn render(frame: &mut Frame<'_>, state: &AppState) -> HitMap {
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(frame.area());
render_header(frame, root[0], state);
match state.screen {
Screen::Dashboard => dashboard::render(frame, state, root[1]),
Screen::Task => task::render(frame, state, root[1]),
Screen::Diagnostics => diagnostics::render(frame, state, root[1]),
Screen::MappingEditor => mapping_editor::render(frame, state, root[1]),
Screen::Recovery => recovery::render(frame, state, root[1]),
Screen::Settings => settings::render(frame, state, root[1]),
}
}
pub fn render_action_strip(
frame: &mut Frame<'_>,
area: Rect,
actions: &[ActionDescriptor],
) -> Vec<HitRegion> {
if actions.is_empty() {
return Vec::new();
}
let columns = action_columns(area.width, actions.len());
let rows = actions.len().div_ceil(columns);
let row_constraints = vec![Constraint::Length(4); rows];
let row_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(row_constraints)
.split(area);
let mut regions = Vec::new();
for (row_idx, row_rect) in row_chunks.iter().copied().enumerate() {
let start = row_idx * columns;
let end = (start + columns).min(actions.len());
let row_actions = &actions[start..end];
let constraints = vec![
Constraint::Percentage((100 / row_actions.len()).max(1) as u16);
row_actions.len()
];
let col_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(row_rect);
for (descriptor, rect) in row_actions.iter().zip(col_chunks.iter().copied()) {
let label_width = rect.width.saturating_sub(4) as usize;
let style = crate::ui::theme::action_label_style(descriptor.active, descriptor.enabled);
let border = crate::ui::theme::border_style(descriptor.active, descriptor.enabled);
let label = truncate_to_width(&descriptor.label, label_width);
let caption = truncate_to_width(&descriptor.caption, label_width);
let body = Paragraph::new(vec![
Line::from(Span::styled(label, style)),
Line::from(Span::styled(
caption,
crate::ui::theme::action_caption_style(descriptor.enabled),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(border)
.title(descriptor.action.label()),
);
frame.render_widget(body, rect);
regions.push(HitRegion {
rect,
target: HitTarget::QuickAction(descriptor.action),
});
}
}
regions
}
pub fn action_grid_height(width: u16, count: usize) -> u16 {
if count == 0 {
0
} else {
let columns = action_columns(width, count);
let rows = count.div_ceil(columns);
(rows as u16).saturating_mul(4)
}
}
pub fn panel_block<'a>(title: &'a str, subtitle: Option<&'a str>, active: bool) -> Block<'a> {
let mut title_spans = vec![Span::styled(title, crate::ui::theme::title_style())];
if let Some(subtitle) = subtitle.filter(|subtitle| !subtitle.is_empty()) {
title_spans.push(Span::styled(" ", crate::ui::theme::subtle_style()));
title_spans.push(Span::styled(subtitle, crate::ui::theme::subtle_style()));
}
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(crate::ui::theme::border_style(active, true))
.title(Line::from(title_spans))
}
pub fn inner_rect(rect: Rect, horizontal: u16, vertical: u16) -> Rect {
let x = rect.x.saturating_add(horizontal);
let y = rect.y.saturating_add(vertical);
let width = rect.width.saturating_sub(horizontal.saturating_mul(2));
let height = rect.height.saturating_sub(vertical.saturating_mul(2));
Rect::new(x, y, width, height)
}
fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let filtered = state.filtered_device_indices().len();
let mode = if state.advanced_mode { "adv" } else { "safe" };
let summary = match state.screen {
Screen::Dashboard => format!("{filtered} devices"),
Screen::Task => truncate_to_width(&state.status_line, 20),
Screen::Diagnostics => state
.diagnostics_state
.as_ref()
.map(|diagnostics| {
let passed = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.ok)
.count();
format!(
"{passed}/{} passed",
diagnostics.result.command_checks.len()
)
})
.unwrap_or_else(|| "diagnostics".to_owned()),
Screen::MappingEditor => {
if state.mapping_has_changes() {
"draft modified".to_owned()
} else {
"draft clean".to_owned()
}
}
Screen::Recovery => "write lock active".to_owned(),
Screen::Settings => "preferences".to_owned(),
};
let line = Line::from(vec![
Span::styled("OpenBitDo", crate::ui::theme::app_title_style()),
Span::raw(" "),
Span::styled(
screen_label(state.screen),
crate::ui::theme::screen_title_style(),
),
Span::raw(""),
Span::styled(summary, crate::ui::theme::subtle_style()),
Span::raw(""),
Span::styled(
format!("reports {}", report_mode_short(state.report_save_mode)),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(mode, crate::ui::theme::subtle_style()),
]);
let header = Paragraph::new(line).block(panel_block("Session", None, true));
frame.render_widget(header, area);
}
fn action_columns(width: u16, count: usize) -> usize {
let desired = if width >= 110 {
4
} else if width >= 76 {
3
} else if width >= 48 {
2
} else {
1
};
desired.min(count.max(1))
}
fn screen_label(screen: Screen) -> &'static str {
match screen {
Screen::Dashboard => "Dashboard",
Screen::Task => "Workflow",
Screen::Diagnostics => "Diagnostics",
Screen::MappingEditor => "Mappings",
Screen::Recovery => "Recovery",
Screen::Settings => "Settings",
}
}
fn report_mode_short(mode: crate::ReportSaveMode) -> &'static str {
match mode {
crate::ReportSaveMode::Off => "off",
crate::ReportSaveMode::Always => "always",
crate::ReportSaveMode::FailureOnly => "fail-only",
}
}
pub fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool {
x >= rect.x
&& y >= rect.y
&& x < rect.x.saturating_add(rect.width)
&& y < rect.y.saturating_add(rect.height)
}
pub fn truncate_to_width(input: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(input) <= max_width {
return input.to_owned();
}
let mut out = String::new();
let mut width = 0usize;
for ch in input.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width >= max_width.saturating_sub(1) {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}

View File

@@ -0,0 +1,3 @@
pub mod layout;
pub mod screens;
pub mod theme;

View File

@@ -0,0 +1,411 @@
use crate::app::state::{AppState, DashboardLayoutMode, PanelFocus};
use crate::ui::layout::{inner_rect, panel_block, truncate_to_width, HitMap, HitRegion, HitTarget};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let mut map = HitMap::default();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(4)])
.split(area);
let status_hint = match state.dashboard_layout_mode {
DashboardLayoutMode::Compact => "compact layout • resize for full three-panel view",
DashboardLayoutMode::Wide => "click • arrows • Enter • Esc/q",
};
let selected_summary = state
.selected_device()
.map(|device| device.name.clone())
.unwrap_or_else(|| "No controller selected".to_owned());
let status = Paragraph::new(vec![
Line::from(Span::raw(state.status_line.clone())),
Line::from(vec![
Span::styled(selected_summary, crate::ui::theme::subtle_style()),
Span::raw(""),
Span::styled(status_hint, crate::ui::theme::subtle_style()),
]),
])
.block(panel_block("Status", None, false));
frame.render_widget(status, root[1]);
match state.dashboard_layout_mode {
DashboardLayoutMode::Wide => render_wide(frame, state, root[0], &mut map),
DashboardLayoutMode::Compact => render_compact(frame, state, root[0], &mut map),
}
map
}
fn render_wide(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(30),
Constraint::Percentage(30),
])
.split(area);
render_devices(frame, state, columns[0], map);
render_selected_device(frame, state, columns[1]);
render_sidebar(frame, state, columns[2], map);
}
fn render_compact(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(9), Constraint::Min(6)])
.split(area);
let top = if area.width < 60 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(rows[0])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(rows[0])
};
render_devices(frame, state, top[0], map);
render_selected_device(frame, state, top[1]);
render_sidebar(frame, state, rows[1], map);
}
fn render_devices(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
let filter_label = if state.last_panel_focus == PanelFocus::Devices {
format!("{} active", state.device_filter)
} else {
state.device_filter.clone()
};
let filter = Paragraph::new(Line::from(vec![
Span::styled("Search ", crate::ui::theme::title_style()),
Span::raw(if filter_label.is_empty() {
"type a model, VID, or PID".to_owned()
} else {
filter_label
}),
]))
.block(panel_block(
"Search",
Some("filter"),
state.last_panel_focus == PanelFocus::Devices,
));
frame.render_widget(filter, chunks[0]);
map.push(chunks[0], HitTarget::FilterInput);
let filtered = state.filtered_device_indices();
let mut rows = Vec::new();
if filtered.is_empty() {
rows.push(ListItem::new(vec![
Line::from("No matching devices"),
Line::from(Span::styled(
"Try a broader search or refresh the device list",
crate::ui::theme::subtle_style(),
)),
]));
} else {
for (display_idx, device_idx) in filtered.iter().copied().enumerate() {
let dev = &state.devices[device_idx];
let selected = state
.selected_device_id
.map(|id| id == dev.vid_pid)
.unwrap_or(false)
|| display_idx == state.selected_filtered_index;
let prefix = if selected { "" } else { " " };
let title = format!(
"{prefix} {:04x}:{:04x} {}",
dev.vid_pid.vid,
dev.vid_pid.pid,
truncate_to_width(&dev.name, chunks[1].width.saturating_sub(18) as usize)
);
let detail = format!(
"{}{}{}",
support_tier_short(dev.support_tier),
protocol_short(dev.protocol_family),
evidence_short(dev.evidence)
);
let style = if selected {
crate::ui::theme::selected_row_style()
} else {
Style::default()
};
rows.push(
ListItem::new(vec![
Line::from(Span::styled(title, style)),
Line::from(Span::styled(detail, crate::ui::theme::subtle_style())),
])
.style(style),
);
}
}
let list = List::new(rows).block(panel_block("Controllers", Some("detected"), true));
frame.render_widget(list, chunks[1]);
map.extend(device_regions(
chunks[1],
state.filtered_device_indices().len(),
));
}
fn render_selected_device(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let selected = state.selected_device();
let lines = if let Some(device) = selected {
let mut details = vec![
Line::from(vec![
Span::styled(device.name.clone(), crate::ui::theme::screen_title_style()),
Span::raw(" "),
Span::styled(
format!("{:04x}:{:04x}", device.vid_pid.vid, device.vid_pid.pid),
crate::ui::theme::subtle_style(),
),
]),
Line::from(format!(
"Support: {}",
support_tier_label(device.support_tier)
)),
Line::from(format!("Protocol: {:?}", device.protocol_family)),
Line::from(format!("Evidence: {:?}", device.evidence)),
Line::from(""),
Line::from("Capabilities"),
];
for capability in capability_lines(device) {
details.push(Line::from(Span::styled(
capability,
crate::ui::theme::subtle_style(),
)));
}
if device.support_tier != bitdo_proto::SupportTier::Full {
details.push(Line::from(""));
details.push(Line::from(Span::styled(
"Write actions stay blocked until hardware confirmation lands.",
crate::ui::theme::warning_style(),
)));
}
details
} else {
vec![
Line::from("No controller selected"),
Line::from(""),
Line::from(Span::styled(
"Refresh the dashboard or connect a device to continue.",
crate::ui::theme::subtle_style(),
)),
]
};
let subtitle = selected
.map(|device| support_tier_short(device.support_tier))
.unwrap_or("idle");
let panel = Paragraph::new(lines).block(panel_block("Device", Some(subtitle), true));
frame.render_widget(panel, area);
}
fn render_sidebar(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(9), Constraint::Min(5)])
.split(area);
render_actions(frame, state, rows[0], map);
render_events(frame, state, rows[1]);
}
fn render_actions(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let lines = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, quick)| {
let prefix = if idx == state.selected_action_index {
""
} else {
" "
};
let caption = quick
.reason
.as_deref()
.map(compact_reason)
.unwrap_or_else(|| action_caption(quick.action).to_owned());
let style = if idx == state.selected_action_index {
crate::ui::theme::selected_row_style()
} else if quick.enabled {
Style::default()
} else {
crate::ui::theme::muted_style()
};
ListItem::new(Line::from(vec![
Span::styled(format!("{prefix} {}", quick.action.label()), style),
Span::raw(""),
Span::styled(caption, crate::ui::theme::subtle_style()),
]))
})
.collect::<Vec<_>>();
let panel = List::new(lines).block(panel_block("Actions", Some("Enter/click"), true));
frame.render_widget(panel, area);
let inner = inner_rect(area, 1, 1);
let visible = inner.height as usize;
for idx in 0..state.quick_actions.len().min(visible) {
map.push(
Rect::new(inner.x, inner.y + idx as u16, inner.width, 1),
HitTarget::QuickAction(state.quick_actions[idx].action),
);
}
}
fn render_events(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let visible = area.height.saturating_sub(2) as usize;
let entries = state
.event_log
.iter()
.rev()
.take(visible)
.rev()
.map(|entry| {
let prefix = entry.timestamp_utc.to_string();
let color = crate::ui::theme::level_color(entry.level);
let level = match entry.level {
crate::app::state::EventLevel::Info => "info",
crate::app::state::EventLevel::Warning => "warn",
crate::app::state::EventLevel::Error => "error",
};
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(color)),
Span::raw(" "),
Span::styled(format!("[{level}]"), Style::default().fg(color)),
Span::raw(" "),
Span::raw(truncate_to_width(
&entry.message,
area.width.saturating_sub(20) as usize,
)),
]))
})
.collect::<Vec<_>>();
let widget = List::new(entries).block(panel_block("Activity", Some("events"), true));
frame.render_widget(widget, area);
}
fn device_regions(list_rect: Rect, total_rows: usize) -> Vec<HitRegion> {
let visible_rows = list_rect.height.saturating_sub(2) as usize / 2;
let max = total_rows.min(visible_rows);
let mut out = Vec::with_capacity(max);
for idx in 0..max {
let rect = Rect::new(
list_rect.x.saturating_add(1),
list_rect
.y
.saturating_add(1 + (idx as u16).saturating_mul(2)),
list_rect.width.saturating_sub(2),
2,
);
out.push(HitRegion {
rect,
target: HitTarget::DeviceRow(idx),
});
}
out
}
fn truncate_reason(reason: &str) -> String {
truncate_to_width(reason, 24)
}
fn action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Refresh => "scan",
crate::app::action::QuickAction::Diagnose => "probe",
crate::app::action::QuickAction::RecommendedUpdate => "safe update",
crate::app::action::QuickAction::EditMappings => "mapping",
crate::app::action::QuickAction::Settings => "prefs",
crate::app::action::QuickAction::Quit => "exit",
_ => "available",
}
}
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "supported",
bitdo_proto::SupportTier::CandidateReadOnly => "read-only",
bitdo_proto::SupportTier::DetectOnly => "detect-only",
}
}
fn support_tier_short(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "full",
bitdo_proto::SupportTier::CandidateReadOnly => "ro",
bitdo_proto::SupportTier::DetectOnly => "detect",
}
}
fn capability_lines(device: &bitdo_app_core::AppDevice) -> Vec<String> {
let mut lines = Vec::new();
if device.capability.supports_firmware {
lines.push("• firmware".to_owned());
}
if device.capability.supports_profile_rw {
lines.push("• profile rw".to_owned());
}
if device.capability.supports_mode {
lines.push("• mode switch".to_owned());
}
if device.capability.supports_jp108_dedicated_map {
lines.push("• JP108 mapping".to_owned());
}
if device.capability.supports_u2_button_map || device.capability.supports_u2_slot_config {
lines.push("• U2 slot + map".to_owned());
}
if lines.is_empty() {
lines.push("• detect only".to_owned());
}
lines
}
fn compact_reason(reason: &str) -> String {
if reason.contains("Read-only") {
"read-only".to_owned()
} else if reason.contains("restart") {
"restart required".to_owned()
} else {
truncate_reason(reason)
}
}
fn protocol_short(protocol: bitdo_proto::ProtocolFamily) -> &'static str {
match protocol {
bitdo_proto::ProtocolFamily::Standard64 => "S64",
bitdo_proto::ProtocolFamily::Unknown => "unknown",
_ => "other",
}
}
fn evidence_short(evidence: bitdo_proto::SupportEvidence) -> &'static str {
match evidence {
bitdo_proto::SupportEvidence::Confirmed => "conf",
bitdo_proto::SupportEvidence::Inferred => "infer",
bitdo_proto::SupportEvidence::Untested => "untest",
}
}

View File

@@ -0,0 +1,543 @@
use crate::app::action::QuickAction;
use crate::app::state::{AppState, DiagnosticsFilter};
use crate::ui::layout::{
action_grid_height, inner_rect, panel_block, render_action_strip, truncate_to_width,
ActionDescriptor, HitMap, HitTarget,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(9),
Constraint::Length(action_height),
])
.split(area);
render_summary(frame, state, rows[0]);
let body = if rows[1].width >= 92 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(44), Constraint::Percentage(56)])
.split(rows[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(rows[1])
};
let mut map = HitMap::default();
render_check_panel(frame, state, body[0], &mut map);
let detail_sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(body[1]);
render_selected_check(frame, state, detail_sections[0]);
render_next_steps(frame, state, detail_sections[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: diagnostics_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn render_summary(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics result loaded.").block(panel_block(
"Diagnostics",
Some("summary"),
true,
));
frame.render_widget(empty, area);
return;
};
let total = diagnostics.result.command_checks.len();
let passed = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.ok)
.count();
let issues = diagnostics
.result
.command_checks
.iter()
.filter(|check| !check.ok || check.severity != bitdo_proto::DiagSeverity::Ok)
.count();
let experimental = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.is_experimental)
.count();
let transport = if diagnostics.result.transport_ready {
"ready"
} else {
"degraded"
};
let lines = vec![
Line::from(vec![
Span::styled(
format!("{passed}/{total} passed"),
crate::ui::theme::screen_title_style(),
),
Span::raw(""),
Span::styled(format!("{issues} issues"), severity_style(issues > 0)),
Span::raw(""),
Span::styled(
format!("{experimental} experimental"),
crate::ui::theme::subtle_style(),
),
]),
Line::from(vec![
Span::styled(
format!(
"Tier: {}",
support_tier_label(diagnostics.result.support_tier)
),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Family: {:?}", diagnostics.result.protocol_family),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Transport: {transport}"),
crate::ui::theme::subtle_style(),
),
]),
];
let panel = Paragraph::new(lines).block(panel_block("Diagnostics", Some("summary"), true));
frame.render_widget(panel, area);
}
fn render_check_panel(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
if area.height < 8 {
render_compact_check_panel(frame, state, area, map);
return;
}
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
render_filter_row(frame, state, sections[0], map);
let filtered = state.diagnostics_filtered_indices();
let items = filtered
.iter()
.map(|check_index| {
let check = &state
.diagnostics_state
.as_ref()
.expect("diagnostics state present")
.result
.command_checks[*check_index];
let selected = state
.diagnostics_state
.as_ref()
.map(|diagnostics| diagnostics.selected_check_index == *check_index)
.unwrap_or(false);
let marker = if selected { "" } else { " " };
let experimental = if check.is_experimental { " exp" } else { "" };
let line = format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, sections[1].width.saturating_sub(26) as usize)
);
let style = if selected {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
};
ListItem::new(line).style(style)
})
.collect::<Vec<_>>();
let list = if items.is_empty() {
List::new(vec![ListItem::new("No checks in this filter")])
} else {
List::new(items)
}
.block(panel_block("Checks", Some("click a row"), true));
frame.render_widget(list, sections[1]);
let list_inner = inner_rect(sections[1], 1, 1);
let visible_rows = list_inner.height as usize;
for filtered_index in 0..filtered.len().min(visible_rows) {
map.push(
Rect::new(
list_inner.x,
list_inner.y + filtered_index as u16,
list_inner.width,
1,
),
HitTarget::DiagnosticsCheck(filtered_index),
);
}
}
fn render_compact_check_panel(
frame: &mut Frame<'_>,
state: &AppState,
area: Rect,
map: &mut HitMap,
) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let filtered = state.diagnostics_filtered_indices();
let inner = inner_rect(area, 1, 1);
let filter_segments = compact_filter_segments(diagnostics);
let filter_line = Line::from(
filter_segments
.iter()
.enumerate()
.map(|(idx, (filter, label))| {
let prefix = if idx == 0 { "" } else { " " };
Span::styled(
format!("{prefix}{label}"),
if diagnostics.active_filter == *filter {
crate::ui::theme::screen_title_style()
} else {
crate::ui::theme::subtle_style()
},
)
})
.collect::<Vec<_>>(),
);
let mut lines = vec![filter_line];
let visible_rows = inner.height.saturating_sub(1) as usize;
if filtered.is_empty() {
lines.push(Line::from("No checks in this filter"));
} else {
for check_index in filtered.iter().take(visible_rows) {
let check = &diagnostics.result.command_checks[*check_index];
let marker = if diagnostics.selected_check_index == *check_index {
""
} else {
" "
};
let experimental = if check.is_experimental { " exp" } else { "" };
lines.push(Line::from(Span::styled(
format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, inner.width.saturating_sub(24) as usize,)
),
if diagnostics.selected_check_index == *check_index {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
},
)));
}
}
let panel = Paragraph::new(lines).block(panel_block("Checks", Some("tab cycles filter"), true));
frame.render_widget(panel, area);
let mut x = inner.x;
for (idx, (filter, label)) in filter_segments.iter().enumerate() {
let text = if idx == 0 {
label.clone()
} else {
format!(" {label}")
};
let width = (text.len() as u16).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
if width == 0 {
break;
}
map.push(
Rect::new(x, inner.y, width, 1),
HitTarget::DiagnosticsFilter(*filter),
);
x = x.saturating_add(text.len() as u16);
}
for (row, _) in filtered.iter().take(visible_rows).enumerate() {
map.push(
Rect::new(
inner.x,
inner.y.saturating_add(row as u16).saturating_add(1),
inner.width,
1,
),
HitTarget::DiagnosticsCheck(row),
);
}
}
fn render_filter_row(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
for (filter, rect) in DiagnosticsFilter::ALL
.into_iter()
.zip(chunks.iter().copied())
{
let active = diagnostics.active_filter == filter;
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let chip = Paragraph::new(vec![
Line::from(Span::styled(
filter.label(),
if active {
crate::ui::theme::screen_title_style()
} else {
Style::default()
},
)),
Line::from(Span::styled(
format!("{count} checks"),
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Filter", Some(filter.label()), active));
frame.render_widget(chip, rect);
map.push(inner_rect(rect, 1, 1), HitTarget::DiagnosticsFilter(filter));
}
}
fn render_selected_check(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let lines = if let Some(check) = state.selected_diagnostics_check() {
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{:?}", check.command),
crate::ui::theme::screen_title_style(),
),
Span::raw(" "),
Span::styled(
severity_badge(check.severity),
severity_row_style(check.severity),
),
]),
Line::from(format!("Severity: {:?}", check.severity)),
Line::from(format!("Confidence: {:?}", check.confidence)),
Line::from(format!(
"Experimental: {}",
if check.is_experimental { "yes" } else { "no" }
)),
Line::from(format!(
"Error code: {}",
check
.error_code
.map(|code| format!("{code:?}"))
.unwrap_or_else(|| "none".to_owned())
)),
Line::from(format!(
"Response: {:?} • attempts {}",
check.response_status, check.attempts
)),
Line::from(format!(
"IO: wrote {}B, read {}B",
check.bytes_written, check.bytes_read
)),
Line::from(format!(
"Validator: {}",
truncate_to_width(&check.validator, area.width.saturating_sub(13) as usize)
)),
Line::from(""),
Line::from(Span::styled("Detail", crate::ui::theme::title_style())),
Line::from(check.detail.clone()),
];
if !check.parsed_facts.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Parsed facts",
crate::ui::theme::title_style(),
)));
for (key, value) in &check.parsed_facts {
lines.push(Line::from(format!("{key}: {value}")));
}
}
lines
} else {
vec![
Line::from("No diagnostics check selected."),
Line::from(""),
Line::from("Change filters or run diagnostics again."),
]
};
let detail = Paragraph::new(lines).block(panel_block("Selected Check", Some("detail"), true));
frame.render_widget(detail, area);
}
fn render_next_steps(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics guidance available.").block(panel_block(
"Next Steps",
Some("guidance"),
true,
));
frame.render_widget(empty, area);
return;
};
let report_line = diagnostics
.latest_report_path
.as_ref()
.map(|path| format!("Saved report: {}", path.display()))
.unwrap_or_else(|| "Saved report: not yet saved in this screen".to_owned());
let content_width = area.width.saturating_sub(4) as usize;
let action_line = format!(
"Action: {}",
truncate_to_width(
recommended_next_action(diagnostics),
content_width.saturating_sub(8)
)
);
let summary_line = format!(
"Summary: {}",
truncate_to_width(&diagnostics.summary, content_width.saturating_sub(9))
);
let report_line = truncate_to_width(&report_line, content_width);
let inner_height = area.height.saturating_sub(2);
let lines = match inner_height {
0 => Vec::new(),
1 => vec![Line::from(if diagnostics.latest_report_path.is_some() {
report_line.clone()
} else {
action_line.clone()
})],
2 => vec![
Line::from(action_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
_ => vec![
Line::from(action_line),
Line::from(summary_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
};
let panel = Paragraph::new(lines).block(panel_block("Next Steps", Some("guidance"), true));
frame.render_widget(panel, area);
}
fn diagnostics_action_caption(action: QuickAction) -> &'static str {
match action {
QuickAction::RunAgain => "rerun safe-read probe",
QuickAction::SaveReport => "write support report",
QuickAction::Back => "return to dashboard",
_ => "available",
}
}
fn recommended_next_action(diagnostics: &crate::app::state::DiagnosticsState) -> &'static str {
match diagnostics.result.support_tier {
bitdo_proto::SupportTier::Full => {
"Return to the dashboard and choose Recommended Update or Edit Mapping if needed."
}
bitdo_proto::SupportTier::CandidateReadOnly => {
"Save or share the report. Update and mapping remain blocked until confirmation lands."
}
bitdo_proto::SupportTier::DetectOnly => {
"Diagnostics only. Do not attempt update or mapping for this device."
}
}
}
fn severity_badge(severity: bitdo_proto::DiagSeverity) -> &'static str {
match severity {
bitdo_proto::DiagSeverity::Ok => "OK",
bitdo_proto::DiagSeverity::Warning => "WARN",
bitdo_proto::DiagSeverity::NeedsAttention => "ATTN",
}
}
fn severity_row_style(severity: bitdo_proto::DiagSeverity) -> Style {
match severity {
bitdo_proto::DiagSeverity::Ok => Style::default().fg(Color::White),
bitdo_proto::DiagSeverity::Warning => crate::ui::theme::warning_style(),
bitdo_proto::DiagSeverity::NeedsAttention => crate::ui::theme::danger_style(),
}
}
fn severity_style(has_issues: bool) -> Style {
if has_issues {
crate::ui::theme::warning_style()
} else {
crate::ui::theme::positive_style()
}
}
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "full",
bitdo_proto::SupportTier::CandidateReadOnly => "candidate-readonly",
bitdo_proto::SupportTier::DetectOnly => "detect-only",
}
}
fn compact_filter_segments(
diagnostics: &crate::app::state::DiagnosticsState,
) -> Vec<(DiagnosticsFilter, String)> {
DiagnosticsFilter::ALL
.into_iter()
.map(|filter| {
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let label = match filter {
DiagnosticsFilter::All => format!("All {count}"),
DiagnosticsFilter::Issues => format!("Issues {count}"),
DiagnosticsFilter::Experimental => format!("Exp {count}"),
};
(filter, label)
})
.collect()
}

View File

@@ -0,0 +1,213 @@
use crate::app::state::{AppState, MappingDraftState};
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(8),
Constraint::Length(action_height),
])
.split(area);
let mut lines = vec![
Line::from(Span::styled(
"Apply is explicit. Arrow keys adjust only the highlighted mapping.",
crate::ui::theme::subtle_style(),
)),
Line::from(""),
];
let mut mapping_rows = Vec::new();
let mut inspector_lines = Vec::new();
match state.mapping_draft_state.as_ref() {
Some(MappingDraftState::Jp108 {
current,
selected_row,
..
}) => {
lines.push(Line::from(Span::styled(
"JP108 dedicated mapping",
crate::ui::theme::screen_title_style(),
)));
for (idx, entry) in current.iter().enumerate() {
let style = if idx == *selected_row {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if idx == *selected_row { "" } else { " " };
mapping_rows.push(
ListItem::new(format!(
"{marker} {:?} -> 0x{:04x}",
entry.button, entry.target_hid_usage
))
.style(style),
);
}
if let Some(selected) = current.get(*selected_row) {
inspector_lines.push(Line::from(format!("Button: {:?}", selected.button)));
inspector_lines.push(Line::from(format!(
"Target HID: 0x{:04x}",
selected.target_hid_usage
)));
inspector_lines.push(Line::from(""));
inspector_lines.push(Line::from("Left/right cycles preset targets."));
}
}
Some(MappingDraftState::Ultimate2 {
current,
selected_row,
..
}) => {
lines.push(Line::from(Span::styled(
format!(
"Ultimate2 profile slot {:?} mode {}",
current.slot, current.mode
),
crate::ui::theme::screen_title_style(),
)));
lines.push(Line::from(Span::styled(
format!("L2 {:.2} | R2 {:.2}", current.l2_analog, current.r2_analog),
crate::ui::theme::subtle_style(),
)));
for (idx, entry) in current.mappings.iter().enumerate() {
let style = if idx == *selected_row {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if idx == *selected_row { "" } else { " " };
mapping_rows.push(
ListItem::new(format!(
"{marker} {:?} -> {} (0x{:04x})",
entry.button,
u2_target_label(entry.target_hid_usage),
entry.target_hid_usage
))
.style(style),
);
}
if let Some(selected) = current.mappings.get(*selected_row) {
inspector_lines.push(Line::from(format!("Button: {:?}", selected.button)));
inspector_lines.push(Line::from(format!(
"Target: {} (0x{:04x})",
u2_target_label(selected.target_hid_usage),
selected.target_hid_usage
)));
inspector_lines.push(Line::from(""));
inspector_lines.push(Line::from("Left/right cycles preset targets."));
}
}
None => {
lines.push(Line::from("No mapping draft loaded."));
inspector_lines.push(Line::from(
"Select Edit Mapping from the dashboard to begin.",
));
}
}
let intro = Paragraph::new(lines).block(panel_block(
"Mapping Studio",
Some(if state.mapping_has_changes() {
"modified"
} else {
"clean"
}),
true,
));
frame.render_widget(intro, rows[0]);
let body = if rows[1].width >= 80 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(rows[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(rows[1])
};
let table = List::new(mapping_rows).block(panel_block(
"Mappings",
Some("up/down select • left/right adjust"),
true,
));
frame.render_widget(table, body[0]);
let mut status_lines = inspector_lines;
status_lines.push(Line::from(""));
status_lines.push(Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)));
let status = Paragraph::new(status_lines).block(panel_block(
"Inspector",
Some("selected mapping"),
true,
));
frame.render_widget(status, body[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: mapping_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn mapping_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::ApplyDraft => "write current draft",
crate::app::action::QuickAction::UndoDraft => "restore last edit",
crate::app::action::QuickAction::ResetDraft => "discard draft changes",
crate::app::action::QuickAction::RestoreBackup => "recover saved backup",
crate::app::action::QuickAction::Firmware => "switch to firmware flow",
crate::app::action::QuickAction::Back => "return to dashboard",
_ => "available",
}
}
fn u2_target_label(target: u16) -> &'static str {
match target {
0x0100 => "A",
0x0101 => "B",
0x0102 => "X",
0x0103 => "Y",
0x0104 => "L1",
0x0105 => "R1",
0x0106 => "L2",
0x0107 => "R2",
0x0108 => "L3",
0x0109 => "R3",
0x010a => "Select",
0x010b => "Start",
0x010c => "Home",
0x010d => "DPadUp",
0x010e => "DPadDown",
0x010f => "DPadLeft",
0x0110 => "DPadRight",
_ => "Unknown",
}
}

View File

@@ -0,0 +1,6 @@
pub mod dashboard;
pub mod diagnostics;
pub mod mapping_editor;
pub mod recovery;
pub mod settings;
pub mod task;

View File

@@ -0,0 +1,76 @@
use crate::app::state::AppState;
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(6),
Constraint::Length(action_height),
])
.split(area);
let body = Paragraph::new(vec![
Line::from(Span::styled(
"Recovery lock is active",
crate::ui::theme::danger_style(),
)),
Line::from(""),
Line::from("Write operations stay blocked until the app restarts."),
Line::from("Restore a backup if one exists, validate the device, then restart."),
])
.block(panel_block("Recovery", Some("safe rollback path"), true));
frame.render_widget(body, rows[0]);
let backup_line = if state.latest_backup.is_some() {
"Backup detected. Restore Backup is available."
} else {
"No backup is registered for this session."
};
let detail = Paragraph::new(vec![
Line::from("1. Restore backup if available."),
Line::from("2. Confirm the controller responds normally."),
Line::from("3. Restart OpenBitDo before any further writes."),
Line::from(""),
Line::from(Span::styled(backup_line, crate::ui::theme::subtle_style())),
Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Guidance", Some("recommended sequence"), true));
frame.render_widget(detail, rows[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: recovery_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn recovery_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::RestoreBackup => "attempt rollback",
crate::app::action::QuickAction::Back => "return to dashboard",
crate::app::action::QuickAction::Quit => "exit openbitdo",
_ => "available",
}
}

View File

@@ -0,0 +1,101 @@
use crate::app::state::AppState;
use crate::ui::layout::{
action_grid_height, inner_rect, panel_block, render_action_strip, ActionDescriptor, HitMap,
HitTarget,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Min(5),
Constraint::Length(action_height),
Constraint::Min(1),
])
.split(area);
let adv = Paragraph::new(vec![
Line::from(Span::styled(
if state.advanced_mode {
"Advanced mode is on"
} else {
"Advanced mode is off"
},
crate::ui::theme::screen_title_style(),
)),
Line::from(Span::styled(
"Toggle to expose expert-only report and workflow options.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Advanced", Some("press t or click"), true));
frame.render_widget(adv, rows[0]);
let report = Paragraph::new(vec![
Line::from(Span::styled(
format!("Support reports: {}", state.report_save_mode.as_str()),
crate::ui::theme::screen_title_style(),
)),
Line::from(Span::styled(
"Cycle report persistence policy with r or mouse.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Reports", Some("press r or click"), true));
frame.render_widget(report, rows[1]);
let settings_path = state
.settings_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "not configured".to_owned());
let status = Paragraph::new(vec![
Line::from(state.status_line.as_str()),
Line::from(""),
Line::from(format!("Config path: {settings_path}")),
Line::from(Span::styled(
"Dashboard layout and filter state persist when a settings path is configured.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Status", Some("saved preferences"), true));
frame.render_widget(status, rows[2]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: settings_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.push(inner_click_rect(rows[0]), HitTarget::ToggleAdvancedMode);
map.push(inner_click_rect(rows[1]), HitTarget::CycleReportMode);
map.extend(render_action_strip(frame, rows[3], &actions));
map
}
fn inner_click_rect(rect: Rect) -> Rect {
inner_rect(rect, 1, 1)
}
fn settings_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Back => "return to dashboard",
crate::app::action::QuickAction::Quit => "exit openbitdo",
_ => "available",
}
}

View File

@@ -0,0 +1,180 @@
use crate::app::state::{AppState, TaskMode};
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(8),
Constraint::Length(action_height),
])
.split(area);
let task = state.task_state.as_ref();
let title = match task.map(|t| t.mode) {
Some(TaskMode::Diagnostics) => "Diagnostics",
Some(TaskMode::Preflight) => "Preflight",
Some(TaskMode::Updating) => "Updating",
Some(TaskMode::Final) => "Result",
None => "Task",
};
let summary_lines = if let Some(task) = task {
vec![
Line::from(vec![
Span::styled(
format!("{title} Workflow"),
crate::ui::theme::screen_title_style(),
),
Span::raw(" "),
Span::styled(
task_mode_caption(task.mode),
crate::ui::theme::subtle_style(),
),
]),
Line::from(""),
Line::from(task.status.clone()),
]
} else {
vec![
Line::from(Span::styled(
"No active workflow",
crate::ui::theme::screen_title_style(),
)),
Line::from(""),
Line::from("Select a device action from the dashboard to begin."),
]
};
let summary =
Paragraph::new(summary_lines).block(panel_block(title, Some("status and intent"), true));
frame.render_widget(summary, rows[0]);
render_task_details(frame, state, rows[1]);
let mut map = HitMap::default();
let action_rows = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: task_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
map.extend(render_action_strip(frame, rows[2], &action_rows));
map
}
fn render_task_details(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let task = state.task_state.as_ref();
let columns = if area.width >= 76 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area)
};
let detail_lines = if let Some(task) = task {
let mut lines = vec![Line::from(task.status.clone())];
if let Some(plan) = task.plan.as_ref() {
lines.push(Line::from(""));
lines.push(Line::from(format!("Session: {:?}", plan.session_id)));
lines.push(Line::from(format!("Chunk size: {} bytes", plan.chunk_size)));
lines.push(Line::from(format!("Chunks: {}", plan.chunks_total)));
lines.push(Line::from(format!("Estimated: {}s", plan.expected_seconds)));
if !plan.warnings.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Warnings",
crate::ui::theme::warning_style(),
)));
for warning in &plan.warnings {
lines.push(Line::from(format!("{warning}")));
}
}
}
if let Some(final_report) = task.final_report.as_ref() {
lines.push(Line::from(""));
lines.push(Line::from(format!(
"Final status: {:?}",
final_report.status
)));
lines.push(Line::from(format!(
"Transfer: {}/{} chunks",
final_report.chunks_sent, final_report.chunks_total
)));
lines.push(Line::from(format!("Message: {}", final_report.message)));
}
lines
} else {
vec![Line::from("No details available")]
};
let detail =
Paragraph::new(detail_lines).block(panel_block("Details", Some("workflow context"), true));
frame.render_widget(detail, columns[0]);
let progress = task.map(|task| task.progress).unwrap_or_default();
let progress_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(4)])
.split(columns[1]);
let gauge = Gauge::default()
.block(panel_block("Progress", Some("transfer state"), true))
.gauge_style(Style::default().fg(ratatui::style::Color::Green))
.percent(progress as u16)
.label(format!("{progress}%"));
frame.render_widget(gauge, progress_rows[0]);
let summary_lines = if let Some(task) = task {
vec![
Line::from(format!("Stage: {}", task_mode_caption(task.mode))),
Line::from(format!("Progress: {progress}%")),
Line::from(format!("Reports: {}", state.report_save_mode.as_str())),
Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)),
]
} else {
vec![Line::from("Select an action to see task details.")]
};
let summary =
Paragraph::new(summary_lines).block(panel_block("Context", Some("current session"), true));
frame.render_widget(summary, progress_rows[1]);
}
fn task_mode_caption(mode: TaskMode) -> &'static str {
match mode {
TaskMode::Diagnostics => "diagnostic probe",
TaskMode::Preflight => "preflight safety check",
TaskMode::Updating => "firmware transfer",
TaskMode::Final => "final outcome",
}
}
fn task_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Confirm => "acknowledge risk + start",
crate::app::action::QuickAction::Cancel => "stop this workflow",
crate::app::action::QuickAction::Back => "return to dashboard",
_ => "available",
}
}

View File

@@ -0,0 +1,83 @@
use ratatui::style::{Color, Modifier, Style};
pub fn app_title_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
pub fn screen_title_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
pub fn title_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
pub fn subtle_style() -> Style {
Style::default().fg(Color::Gray)
}
pub fn muted_style() -> Style {
Style::default().fg(Color::DarkGray)
}
pub fn positive_style() -> Style {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
}
pub fn warning_style() -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
pub fn danger_style() -> Style {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
}
pub fn selected_row_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
pub fn action_label_style(active: bool, enabled: bool) -> Style {
match (active, enabled) {
(true, true) => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
(false, true) => Style::default().fg(Color::White),
(_, false) => muted_style(),
}
}
pub fn action_caption_style(enabled: bool) -> Style {
if enabled {
subtle_style()
} else {
muted_style()
}
}
pub fn border_style(active: bool, enabled: bool) -> Style {
match (active, enabled) {
(true, true) => Style::default().fg(Color::Cyan),
(false, true) => Style::default().fg(Color::Gray),
(_, false) => Style::default().fg(Color::DarkGray),
}
}
pub fn level_color(level: crate::app::state::EventLevel) -> Color {
match level {
crate::app::state::EventLevel::Info => Color::White,
crate::app::state::EventLevel::Warning => Color::Yellow,
crate::app::state::EventLevel::Error => Color::Red,
}
}

View File

@@ -15,6 +15,7 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
bitdo_app_core = { path = "../bitdo_app_core" }
bitdo_tui = { path = "../bitdo_tui" }
bitdo_proto = { path = "../bitdo_proto" }
[dev-dependencies]
assert_cmd = "2.0"

View File

@@ -1,5 +1,5 @@
use bitdo_app_core::{signing_key_fingerprint_active_sha256, signing_key_fingerprint_next_sha256};
use bitdo_tui::ReportSaveMode;
use bitdo_tui::{DashboardLayoutMode, PanelFocus, ReportSaveMode};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -89,6 +89,12 @@ pub struct UserSettings {
pub advanced_mode: bool,
#[serde(default)]
pub report_save_mode: ReportSaveMode,
#[serde(default)]
pub device_filter_text: String,
#[serde(default)]
pub dashboard_layout_mode: DashboardLayoutMode,
#[serde(default)]
pub last_panel_focus: PanelFocus,
}
impl Default for UserSettings {
@@ -97,12 +103,15 @@ impl Default for UserSettings {
schema_version: default_settings_schema_version(),
advanced_mode: false,
report_save_mode: ReportSaveMode::FailureOnly,
device_filter_text: String::new(),
dashboard_layout_mode: DashboardLayoutMode::Wide,
last_panel_focus: PanelFocus::Devices,
}
}
}
const fn default_settings_schema_version() -> u32 {
1
2
}
pub fn user_settings_path() -> PathBuf {
@@ -130,15 +139,17 @@ pub fn user_settings_path() -> PathBuf {
std::env::temp_dir().join("openbitdo").join("config.toml")
}
pub fn load_user_settings(path: &Path) -> UserSettings {
pub fn load_user_settings(path: &Path) -> anyhow::Result<UserSettings> {
let Ok(raw) = std::fs::read_to_string(path) else {
return UserSettings::default();
return Ok(UserSettings::default());
};
let mut settings: UserSettings = toml::from_str(&raw).unwrap_or_default();
let mut settings: UserSettings = toml::from_str(&raw)
.map_err(|err| anyhow::anyhow!("failed to parse settings {}: {err}", path.display()))?;
settings.schema_version = default_settings_schema_version();
if !settings.advanced_mode && settings.report_save_mode == ReportSaveMode::Off {
settings.report_save_mode = ReportSaveMode::FailureOnly;
}
settings
Ok(settings)
}
pub fn save_user_settings(path: &Path, settings: &UserSettings) -> anyhow::Result<()> {
@@ -194,16 +205,19 @@ mod tests {
}
#[test]
fn settings_roundtrip_toml() {
fn settings_roundtrip_toml_v2() {
let tmp =
std::env::temp_dir().join(format!("openbitdo-settings-{}.toml", std::process::id()));
std::env::temp_dir().join(format!("openbitdo-settings-v2-{}.toml", std::process::id()));
let settings = UserSettings {
schema_version: 1,
schema_version: 2,
advanced_mode: true,
report_save_mode: ReportSaveMode::Always,
device_filter_text: "ultimate".to_owned(),
dashboard_layout_mode: DashboardLayoutMode::Compact,
last_panel_focus: PanelFocus::QuickActions,
};
save_user_settings(&tmp, &settings).expect("save settings");
let loaded = load_user_settings(&tmp);
let loaded = load_user_settings(&tmp).expect("load settings");
assert_eq!(loaded, settings);
let _ = std::fs::remove_file(tmp);
}
@@ -211,8 +225,35 @@ mod tests {
#[test]
fn missing_settings_uses_defaults() {
let path = PathBuf::from("/tmp/openbitdo-nonexistent-settings.toml");
let loaded = load_user_settings(&path);
let loaded = load_user_settings(&path).expect("load defaults");
assert!(!loaded.advanced_mode);
assert_eq!(loaded.report_save_mode, ReportSaveMode::FailureOnly);
assert_eq!(loaded.schema_version, 2);
}
#[test]
fn v1_settings_migrate_to_v2_defaults() {
let path = std::env::temp_dir().join("openbitdo-v1-migrate.toml");
std::fs::write(
&path,
"schema_version = 1\nadvanced_mode = true\nreport_save_mode = \"always\"\n",
)
.expect("write v1");
let loaded = load_user_settings(&path).expect("load migrated settings");
assert_eq!(loaded.schema_version, 2);
assert_eq!(loaded.device_filter_text, "");
assert_eq!(loaded.dashboard_layout_mode, DashboardLayoutMode::Wide);
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_settings_returns_error() {
let path = std::env::temp_dir().join("openbitdo-invalid-settings.toml");
std::fs::write(&path, "advanced_mode = [").expect("write invalid");
let err = load_user_settings(&path).expect_err("invalid settings must error");
assert!(err.to_string().contains("failed to parse settings"));
let _ = std::fs::remove_file(path);
}
}

View File

@@ -1,8 +1,8 @@
use anyhow::Result;
use bitdo_app_core::{OpenBitdoCore, OpenBitdoCoreConfig};
use bitdo_tui::{run_tui_app, TuiLaunchOptions};
use bitdo_tui::{run_ui, UiLaunchOptions};
use clap::Parser;
use openbitdo::{load_user_settings, user_settings_path, BuildInfo};
use openbitdo::{load_user_settings, user_settings_path, BuildInfo, UserSettings};
#[derive(Debug, Parser)]
#[command(name = "openbitdo")]
@@ -15,9 +15,19 @@ struct Cli {
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
let settings_path = user_settings_path();
let settings = load_user_settings(&settings_path);
let settings = match load_user_settings(&settings_path) {
Ok(settings) => settings,
Err(err) => {
eprintln!(
"warning: failed to load settings from {}: {err}; using defaults",
settings_path.display()
);
UserSettings::default()
}
};
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: cli.mock,
@@ -25,9 +35,10 @@ async fn main() -> Result<()> {
progress_interval_ms: 5,
..Default::default()
});
run_tui_app(
run_ui(
core,
TuiLaunchOptions {
UiLaunchOptions {
build_info: BuildInfo::current().to_tui_info(),
advanced_mode: settings.advanced_mode,
report_save_mode: settings.report_save_mode,
@@ -36,6 +47,7 @@ async fn main() -> Result<()> {
},
)
.await?;
Ok(())
}
@@ -51,7 +63,21 @@ mod tests {
}
#[test]
fn cli_rejects_cmd_subcommand() {
fn cli_rejects_ui_subcommand_form() {
let err = Cli::try_parse_from(["openbitdo", "ui", "--mock"]).expect_err("must reject ui");
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn cli_rejects_run_subcommand_form() {
let err =
Cli::try_parse_from(["openbitdo", "run", "--vidpid", "2dc8:6009", "--recommended"])
.expect_err("must reject run");
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn cli_rejects_legacy_cmd_subcommand() {
let err = Cli::try_parse_from(["openbitdo", "cmd"]).expect_err("must reject cmd");
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
}

View File

@@ -2,12 +2,27 @@ use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
#[test]
fn help_mentions_beginner_flow() {
fn help_mentions_single_command_surface() {
let mut cmd = cargo_bin_cmd!("openbitdo");
cmd.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("beginner-first"))
.stdout(predicate::str::contains("Usage: openbitdo [OPTIONS]"))
.stdout(predicate::str::contains("--mock"))
.stdout(predicate::str::contains("cmd").not());
.stdout(predicate::str::contains("ui").not())
.stdout(predicate::str::contains("run").not());
}
#[test]
fn rejects_ui_subcommand_form() {
let mut cmd = cargo_bin_cmd!("openbitdo");
cmd.args(["ui", "--mock"]).assert().failure();
}
#[test]
fn rejects_run_subcommand_form() {
let mut cmd = cargo_bin_cmd!("openbitdo");
cmd.args(["run", "--vidpid", "2dc8:6009", "--recommended"])
.assert()
.failure();
}

View File

@@ -12,4 +12,20 @@ if rg -n --hidden -g '!target/**' -g '!scripts/cleanroom_guard.sh' "$forbidden_p
exit 1
fi
# Prevent stale subcommand-era examples in active user docs.
active_docs=(../README.md README.md ../MIGRATION.md)
stale_command_pattern='cargo run -p openbitdo -- ui([[:space:]]|$)|cargo run -p openbitdo -- run([[:space:]]|$)|(^|[^[:alnum:]_])openbitdo ui([[:space:]]|$)|(^|[^[:alnum:]_])openbitdo run([[:space:]]|$)'
if rg -n "$stale_command_pattern" "${active_docs[@]}" | rg -v '\(legacy\)' | rg -v '\(historical\)'; then
echo "cleanroom guard failed: stale openbitdo subcommand surface found in active docs"
echo "expected current usage: openbitdo [--mock]"
exit 1
fi
stale_aur_pattern='packaging/aur/openbitdo(/|$)|`openbitdo` \(source build\)'
if rg -n "$stale_aur_pattern" "${active_docs[@]}" | rg -v '\(legacy\)' | rg -v '\(historical\)'; then
echo "cleanroom guard failed: stale source AUR package reference found in active docs"
echo "expected current AUR package surface: openbitdo-bin only"
exit 1
fi
echo "cleanroom guard passed"

View File

@@ -1,50 +1,15 @@
use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid};
use bitdo_proto::{
find_command, CommandId, DeviceSession, MockTransport, SafetyClass, SessionConfig, VidPid,
};
#[test]
fn diag_probe_returns_command_checks() {
fn diag_probe_expands_to_safe_read_commands_and_parsed_facts() {
let mut transport = MockTransport::default();
let mut pid = vec![0u8; 64];
pid[0] = 0x02;
pid[1] = 0x05;
pid[4] = 0xC1;
pid[22] = 0x09;
pid[23] = 0x60;
transport.push_read_data(pid);
let mut rr = vec![0u8; 64];
rr[0] = 0x02;
rr[1] = 0x04;
rr[5] = 0x01;
transport.push_read_data(rr);
let mut mode = vec![0u8; 64];
mode[0] = 0x02;
mode[1] = 0x05;
mode[5] = 2;
transport.push_read_data(mode);
let mut ver = vec![0u8; 64];
ver[0] = 0x02;
ver[1] = 0x22;
ver[2] = 0x2A;
ver[3] = 0x00;
ver[4] = 1;
transport.push_read_data(ver);
let mut super_button = vec![0u8; 64];
super_button[0] = 0x02;
super_button[1] = 0x05;
transport.push_read_data(super_button);
let mut profile = vec![0u8; 64];
profile[0] = 0x02;
profile[1] = 0x05;
transport.push_read_data(profile);
push_diag_success_sequence_for_u2(&mut transport);
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, 24585),
VidPid::new(0x2dc8, 0x6012),
SessionConfig {
experimental: true,
..Default::default()
@@ -53,6 +18,177 @@ fn diag_probe_returns_command_checks() {
.expect("session init");
let diag = session.diag_probe();
assert_eq!(diag.command_checks.len(), 6);
assert!(diag.command_checks.iter().all(|c| c.ok));
assert_eq!(diag.command_checks.len(), 12);
assert!(diag.transport_ready);
assert!(diag.command_checks.iter().all(|check| {
find_command(check.command)
.map(|row| row.safety_class == SafetyClass::SafeRead)
.unwrap_or(false)
}));
assert!(diag
.command_checks
.iter()
.any(|check| check.command == CommandId::U2GetCurrentSlot));
let pid_check = diag
.command_checks
.iter()
.find(|check| check.command == CommandId::GetPid)
.expect("pid check");
assert_eq!(
pid_check.parsed_facts.get("detected_pid").copied(),
Some(0x6012)
);
assert_eq!(pid_check.response_status, bitdo_proto::ResponseStatus::Ok);
let revision_check = diag
.command_checks
.iter()
.find(|check| check.command == CommandId::GetReportRevision)
.expect("revision check");
assert_eq!(
revision_check.parsed_facts.get("revision").copied(),
Some(1)
);
let version_check = diag
.command_checks
.iter()
.find(|check| check.command == CommandId::GetControllerVersion)
.expect("version check");
assert_eq!(
version_check.parsed_facts.get("version_x100").copied(),
Some(42)
);
assert_eq!(version_check.parsed_facts.get("beta").copied(), Some(1));
let slot_check = diag
.command_checks
.iter()
.find(|check| check.command == CommandId::U2GetCurrentSlot)
.expect("slot check");
assert_eq!(slot_check.parsed_facts.get("slot").copied(), Some(2));
}
#[test]
fn diag_probe_get_mode_falls_back_to_get_mode_alt() {
let mut transport = MockTransport::default();
push_diag_sequence_with_mode_fallback(&mut transport);
let mut session = DeviceSession::new(
transport,
VidPid::new(0x2dc8, 0x6002),
SessionConfig {
experimental: true,
..Default::default()
},
)
.expect("session init");
let diag = session.diag_probe();
let mode_check = diag
.command_checks
.iter()
.find(|check| check.command == CommandId::GetMode)
.expect("mode check");
assert!(mode_check.ok);
assert_eq!(mode_check.parsed_facts.get("mode").copied(), Some(7));
assert!(mode_check.detail.contains("GetModeAlt fallback"));
assert_eq!(mode_check.response_status, bitdo_proto::ResponseStatus::Ok);
}
fn push_diag_success_sequence_for_u2(transport: &mut MockTransport) {
transport.push_read_data(pid_response(0x6012));
transport.push_read_data(report_revision_response(1));
transport.push_read_data(mode_response(2));
transport.push_read_data(mode_response(2));
transport.push_read_data(version_response(42, 1));
transport.push_read_data(ok_read_response());
transport.push_read_data(idle_response());
transport.push_read_data(version_response(42, 1));
transport.push_read_data(ok_read_response());
transport.push_read_data(slot_response(2));
transport.push_read_data(ok_read_response());
transport.push_read_data(ok_read_response());
}
fn push_diag_sequence_with_mode_fallback(transport: &mut MockTransport) {
transport.push_read_data(pid_response(0x6002));
transport.push_read_data(report_revision_response(1));
transport.push_read_data(invalid_mode_response());
transport.push_read_data(invalid_mode_response());
transport.push_read_data(invalid_mode_response());
transport.push_read_data(mode_response(7));
transport.push_read_data(mode_response(7));
transport.push_read_data(version_response(99, 0));
transport.push_read_data(idle_response());
transport.push_read_data(version_response(99, 0));
transport.push_read_data(ok_read_response());
}
fn pid_response(pid: u16) -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x05;
response[4] = 0xC1;
response[22] = (pid & 0x00ff) as u8;
response[23] = (pid >> 8) as u8;
response
}
fn report_revision_response(revision: u8) -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x04;
response[5] = revision;
response
}
fn mode_response(mode: u8) -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x05;
response[5] = mode;
response
}
fn invalid_mode_response() -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x00;
response[1] = 0x00;
response
}
fn version_response(version_x100: u16, beta: u8) -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x22;
let bytes = version_x100.to_le_bytes();
response[2] = bytes[0];
response[3] = bytes[1];
response[4] = beta;
response
}
fn slot_response(slot: u8) -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x05;
response[5] = slot;
response
}
fn ok_read_response() -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response[1] = 0x05;
response
}
fn idle_response() -> Vec<u8> {
let mut response = vec![0u8; 64];
response[0] = 0x02;
response
}

View File

@@ -1,6 +1,6 @@
use bitdo_proto::{
find_command, BitdoError, CommandId, CommandRuntimePolicy, DeviceSession, DiagSeverity,
EvidenceConfidence, MockTransport, SessionConfig, VidPid,
EvidenceConfidence, MockTransport, ResponseStatus, SessionConfig, VidPid,
};
#[test]
@@ -71,6 +71,9 @@ fn diag_probe_marks_inferred_reads_as_experimental() {
.expect("inferred check present");
assert!(inferred.is_experimental);
assert_eq!(inferred.confidence, EvidenceConfidence::Inferred);
assert!(inferred.attempts >= 1);
assert_eq!(inferred.response_status, ResponseStatus::Malformed);
assert!(inferred.bytes_written > 0);
assert!(matches!(
inferred.severity,
DiagSeverity::Ok | DiagSeverity::Warning | DiagSeverity::NeedsAttention