mirror of
https://github.com/bybrooklyn/openbitdo.git
synced 2026-03-19 12:12:57 -04:00
Compare commits
3 Commits
v0.0.1-rc.
...
v0.0.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 86875075fc | |||
|
|
aaa321e9ff | ||
|
|
3e46a36693 |
13
.github/workflows/aur-publish.yml
vendored
13
.github/workflows/aur-publish.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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" \
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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
|
||||
|
||||
77
MIGRATION.md
77
MIGRATION.md
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
70
process/aur_publish_troubleshooting.md
Normal file
70
process/aur_publish_troubleshooting.md
Normal 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
69
sdk/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
45
sdk/crates/bitdo_tui/src/app/action.rs
Normal file
45
sdk/crates/bitdo_tui/src/app/action.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
69
sdk/crates/bitdo_tui/src/app/effect.rs
Normal file
69
sdk/crates/bitdo_tui/src/app/effect.rs
Normal 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>,
|
||||
},
|
||||
}
|
||||
85
sdk/crates/bitdo_tui/src/app/event.rs
Normal file
85
sdk/crates/bitdo_tui/src/app/event.rs
Normal 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),
|
||||
}
|
||||
5
sdk/crates/bitdo_tui/src/app/mod.rs
Normal file
5
sdk/crates/bitdo_tui/src/app/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod action;
|
||||
pub mod effect;
|
||||
pub mod event;
|
||||
pub mod reducer;
|
||||
pub mod state;
|
||||
882
sdk/crates/bitdo_tui/src/app/reducer.rs
Normal file
882
sdk/crates/bitdo_tui/src/app/reducer.rs
Normal 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()]
|
||||
}
|
||||
634
sdk/crates/bitdo_tui/src/app/state.rs
Normal file
634
sdk/crates/bitdo_tui/src/app/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
301
sdk/crates/bitdo_tui/src/headless/mod.rs
Normal file
301
sdk/crates/bitdo_tui/src/headless/mod.rs
Normal 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
1
sdk/crates/bitdo_tui/src/persistence/mod.rs
Normal file
1
sdk/crates/bitdo_tui/src/persistence/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod ui_state;
|
||||
82
sdk/crates/bitdo_tui/src/persistence/ui_state.rs
Normal file
82
sdk/crates/bitdo_tui/src/persistence/ui_state.rs
Normal 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(())
|
||||
}
|
||||
314
sdk/crates/bitdo_tui/src/runtime/effect_executor.rs
Normal file
314
sdk/crates/bitdo_tui/src/runtime/effect_executor.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
281
sdk/crates/bitdo_tui/src/runtime/loop.rs
Normal file
281
sdk/crates/bitdo_tui/src/runtime/loop.rs
Normal 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(())
|
||||
}
|
||||
2
sdk/crates/bitdo_tui/src/runtime/mod.rs
Normal file
2
sdk/crates/bitdo_tui/src/runtime/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod effect_executor;
|
||||
pub mod r#loop;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯
|
||||
@@ -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 │
|
||||
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯
|
||||
@@ -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 │
|
||||
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
283
sdk/crates/bitdo_tui/src/ui/layout.rs
Normal file
283
sdk/crates/bitdo_tui/src/ui/layout.rs
Normal 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
|
||||
}
|
||||
3
sdk/crates/bitdo_tui/src/ui/mod.rs
Normal file
3
sdk/crates/bitdo_tui/src/ui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod layout;
|
||||
pub mod screens;
|
||||
pub mod theme;
|
||||
411
sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs
Normal file
411
sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
543
sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs
Normal file
543
sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs
Normal 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()
|
||||
}
|
||||
213
sdk/crates/bitdo_tui/src/ui/screens/mapping_editor.rs
Normal file
213
sdk/crates/bitdo_tui/src/ui/screens/mapping_editor.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
6
sdk/crates/bitdo_tui/src/ui/screens/mod.rs
Normal file
6
sdk/crates/bitdo_tui/src/ui/screens/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod dashboard;
|
||||
pub mod diagnostics;
|
||||
pub mod mapping_editor;
|
||||
pub mod recovery;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
76
sdk/crates/bitdo_tui/src/ui/screens/recovery.rs
Normal file
76
sdk/crates/bitdo_tui/src/ui/screens/recovery.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
101
sdk/crates/bitdo_tui/src/ui/screens/settings.rs
Normal file
101
sdk/crates/bitdo_tui/src/ui/screens/settings.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
180
sdk/crates/bitdo_tui/src/ui/screens/task.rs
Normal file
180
sdk/crates/bitdo_tui/src/ui/screens/task.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
83
sdk/crates/bitdo_tui/src/ui/theme.rs
Normal file
83
sdk/crates/bitdo_tui/src/ui/theme.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user