mirror of
https://github.com/bybrooklyn/openbitdo.git
synced 2026-03-19 12:12:57 -04:00
Compare commits
5 Commits
v0.0.1-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 900563addc | |||
| da09094d3e | |||
| 86875075fc | |||
|
|
aaa321e9ff | ||
|
|
3e46a36693 |
2
.github/ISSUE_TEMPLATE/release-blocker.yml
vendored
2
.github/ISSUE_TEMPLATE/release-blocker.yml
vendored
@@ -36,7 +36,7 @@ body:
|
|||||||
id: openbitdo_version
|
id: openbitdo_version
|
||||||
attributes:
|
attributes:
|
||||||
label: OpenBitdo version
|
label: OpenBitdo version
|
||||||
placeholder: v0.0.1-rc.1
|
placeholder: v0.0.1-rc.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
13
.github/workflows/aur-publish.yml
vendored
13
.github/workflows/aur-publish.yml
vendored
@@ -4,13 +4,13 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Release tag to publish (for example: v0.0.1-rc.1)"
|
description: "Release tag to publish (for example: v0.0.1-rc.4)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: "Release tag to publish (for example: v0.0.1-rc.1)"
|
description: "Release tag to publish (for example: v0.0.1-rc.4)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -54,8 +54,6 @@ jobs:
|
|||||||
--pattern "openbitdo-${TAG}-linux-aarch64.tar.gz" \
|
--pattern "openbitdo-${TAG}-linux-aarch64.tar.gz" \
|
||||||
--pattern "openbitdo-${TAG}-macos-arm64.tar.gz" \
|
--pattern "openbitdo-${TAG}-macos-arm64.tar.gz" \
|
||||||
--dir /tmp/release-input
|
--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 \
|
bash packaging/scripts/render_release_metadata.sh \
|
||||||
"$TAG" \
|
"$TAG" \
|
||||||
"$GITHUB_REPOSITORY" \
|
"$GITHUB_REPOSITORY" \
|
||||||
@@ -64,8 +62,6 @@ jobs:
|
|||||||
useradd -m builder
|
useradd -m builder
|
||||||
chown -R builder:builder /tmp/release-metadata
|
chown -R builder:builder /tmp/release-metadata
|
||||||
su builder -s /bin/bash -c "set -euo pipefail; \
|
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; \
|
cd /tmp/release-metadata/aur/openbitdo-bin; \
|
||||||
makepkg --printsrcinfo > .SRCINFO"
|
makepkg --printsrcinfo > .SRCINFO"
|
||||||
|
|
||||||
@@ -74,8 +70,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: aur-rendered-metadata-${{ inputs.tag }}
|
name: aur-rendered-metadata-${{ inputs.tag }}
|
||||||
path: |
|
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/PKGBUILD
|
||||||
/tmp/release-metadata/aur/openbitdo-bin/.SRCINFO
|
/tmp/release-metadata/aur/openbitdo-bin/.SRCINFO
|
||||||
/tmp/release-metadata/checksums.env
|
/tmp/release-metadata/checksums.env
|
||||||
@@ -96,7 +90,7 @@ jobs:
|
|||||||
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
|
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
|
||||||
chmod 644 "$HOME/.ssh/known_hosts"
|
chmod 644 "$HOME/.ssh/known_hosts"
|
||||||
|
|
||||||
- name: Publish openbitdo and openbitdo-bin
|
- name: Publish openbitdo-bin
|
||||||
env:
|
env:
|
||||||
GIT_SSH_COMMAND: ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=accept-new
|
GIT_SSH_COMMAND: ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=accept-new
|
||||||
run: |
|
run: |
|
||||||
@@ -141,5 +135,4 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_pkg openbitdo
|
|
||||||
publish_pkg openbitdo-bin
|
publish_pkg openbitdo-bin
|
||||||
|
|||||||
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -30,34 +30,33 @@ jobs:
|
|||||||
container: archlinux:base
|
container: archlinux:base
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install AUR packaging toolchain
|
- name: Install packaging validation toolchain
|
||||||
run: |
|
run: |
|
||||||
pacman -Sy --noconfirm --needed base-devel git
|
pacman -Sy --noconfirm --needed base-devel git jq
|
||||||
- name: Ensure package metadata has pinned checksum fields
|
- name: Validate release metadata rendering
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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/scripts/render_release_metadata.sh
|
||||||
test -f packaging/aur/openbitdo/PKGBUILD.tmpl
|
|
||||||
test -f packaging/aur/openbitdo-bin/PKGBUILD.tmpl
|
test -f packaging/aur/openbitdo-bin/PKGBUILD.tmpl
|
||||||
test -f packaging/homebrew/Formula/openbitdo.rb.tmpl
|
test -f packaging/homebrew/Formula/openbitdo.rb.tmpl
|
||||||
|
test ! -f packaging/homebrew/Formula/openbitdo.rb
|
||||||
|
bash packaging/scripts/test_render_release_metadata.sh
|
||||||
|
- name: Validate Homebrew tap sync helper
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bash packaging/homebrew/test_sync_tap.sh
|
||||||
- name: Validate PKGBUILD and .SRCINFO
|
- name: Validate PKGBUILD and .SRCINFO
|
||||||
run: |
|
run: |
|
||||||
useradd -m builder
|
useradd -m builder
|
||||||
chown -R builder:builder "$GITHUB_WORKSPACE"
|
chown -R builder:builder "$GITHUB_WORKSPACE"
|
||||||
su builder -s /bin/bash -c "set -euo pipefail; \
|
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'; \
|
cd '$GITHUB_WORKSPACE/packaging/aur/openbitdo-bin'; \
|
||||||
makepkg --printsrcinfo > /tmp/openbitdo-bin.srcinfo; \
|
makepkg --printsrcinfo > /tmp/openbitdo-bin.srcinfo; \
|
||||||
diff -u .SRCINFO /tmp/openbitdo-bin.srcinfo"
|
diff -u .SRCINFO /tmp/openbitdo-bin.srcinfo"
|
||||||
|
- name: Check docs consistency
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
bash sdk/scripts/check_docs_consistency.sh
|
||||||
|
|
||||||
tui-smoke-test:
|
tui-smoke-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
82
.github/workflows/homebrew-publish.yml
vendored
Normal file
82
.github/workflows/homebrew-publish.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Homebrew Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to publish (for example: v0.0.1-rc.4)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to publish (for example: v0.0.1-rc.4)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-homebrew:
|
||||||
|
if: vars.HOMEBREW_PUBLISH_ENABLED == '1'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ inputs.tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Wait for release assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
echo "release ${TAG} is available"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "release ${TAG} was not found after waiting" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Render Homebrew formula from released assets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/release-input /tmp/release-metadata
|
||||||
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||||
|
--pattern "openbitdo-${TAG}-linux-x86_64.tar.gz" \
|
||||||
|
--pattern "openbitdo-${TAG}-linux-aarch64.tar.gz" \
|
||||||
|
--pattern "openbitdo-${TAG}-macos-arm64.tar.gz" \
|
||||||
|
--dir /tmp/release-input
|
||||||
|
bash packaging/scripts/render_release_metadata.sh \
|
||||||
|
"$TAG" \
|
||||||
|
"$GITHUB_REPOSITORY" \
|
||||||
|
/tmp/release-input \
|
||||||
|
/tmp/release-metadata
|
||||||
|
|
||||||
|
- name: Upload rendered Homebrew formula (audit)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: homebrew-rendered-formula-${{ inputs.tag }}
|
||||||
|
path: |
|
||||||
|
/tmp/release-metadata/homebrew/Formula/openbitdo.rb
|
||||||
|
/tmp/release-metadata/checksums.env
|
||||||
|
|
||||||
|
- name: Sync Homebrew tap
|
||||||
|
env:
|
||||||
|
HOMEBREW_PUBLISH_ENABLED: "1"
|
||||||
|
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }}
|
||||||
|
FORMULA_SOURCE: /tmp/release-metadata/homebrew/Formula/openbitdo.rb
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then
|
||||||
|
echo "missing required secret: HOMEBREW_TAP_TOKEN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${HOMEBREW_TAP_REPO:-}" ]]; then
|
||||||
|
echo "missing required variable: HOMEBREW_TAP_REPO" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash packaging/homebrew/sync_tap.sh
|
||||||
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
@@ -269,63 +269,11 @@ jobs:
|
|||||||
|
|
||||||
publish-homebrew:
|
publish-homebrew:
|
||||||
if: vars.HOMEBREW_PUBLISH_ENABLED == '1'
|
if: vars.HOMEBREW_PUBLISH_ENABLED == '1'
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: publish
|
needs: publish
|
||||||
env:
|
uses: ./.github/workflows/homebrew-publish.yml
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Wait for release assets
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in $(seq 1 30); do
|
|
||||||
if gh release view "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
echo "release ${GITHUB_REF_NAME} was not found after waiting" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Render Homebrew formula with release checksums
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p /tmp/release-input /tmp/release-metadata
|
|
||||||
gh release download "${GITHUB_REF_NAME}" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--pattern "openbitdo-${GITHUB_REF_NAME}-linux-x86_64.tar.gz" \
|
|
||||||
--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" \
|
|
||||||
/tmp/release-input \
|
|
||||||
/tmp/release-metadata
|
|
||||||
|
|
||||||
- name: Upload rendered Homebrew formula (audit)
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: homebrew-rendered-formula-${{ github.ref_name }}
|
tag: ${{ github.ref_name }}
|
||||||
path: |
|
secrets: inherit
|
||||||
/tmp/release-metadata/homebrew/Formula/openbitdo.rb
|
|
||||||
/tmp/release-metadata/checksums.env
|
|
||||||
|
|
||||||
- name: Sync Homebrew tap
|
|
||||||
env:
|
|
||||||
HOMEBREW_PUBLISH_ENABLED: "1"
|
|
||||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
||||||
HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }}
|
|
||||||
FORMULA_SOURCE: /tmp/release-metadata/homebrew/Formula/openbitdo.rb
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then
|
|
||||||
echo "missing required secret: HOMEBREW_TAP_TOKEN" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
bash packaging/homebrew/sync_tap.sh
|
|
||||||
|
|
||||||
publish-aur:
|
publish-aur:
|
||||||
if: vars.AUR_PUBLISH_ENABLED == '1'
|
if: vars.AUR_PUBLISH_ENABLED == '1'
|
||||||
|
|||||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,24 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to OpenBitdo are tracked here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## v0.0.1-rc.4
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Release docs are being rewritten around the `v0.0.1-rc.4` flow.
|
||||||
|
- Homebrew publishing is being moved to a reusable workflow with the separate tap repo kept as the canonical Homebrew destination.
|
||||||
|
- TUI copy is being expanded so first-run guidance is clearer and blocked-action reasons are easier to understand.
|
||||||
|
- The checked-in Homebrew formula output is being removed; the template and rendered release metadata remain the source of truth.
|
||||||
|
|
||||||
|
## v0.0.1-rc.3
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Tag-driven GitHub prerelease assets for Linux `x86_64`, Linux `aarch64`, and macOS arm64.
|
||||||
|
- AUR publication for `openbitdo-bin` with release-derived checksums.
|
||||||
|
- Diagnostics screen with richer per-check detail and saved-report flow.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Firmware update defaults remain safe until the user explicitly acknowledges risk.
|
||||||
|
- Temporary recommended-firmware downloads are cleaned up after success, failure, or cancellation.
|
||||||
|
- Invalid persisted settings are surfaced as warnings instead of being silently discarded.
|
||||||
|
|
||||||
## v0.0.1-rc.1
|
## v0.0.1-rc.1
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Beginner-first `openbitdo` TUI flow with device-specific JP108 and Ultimate2 guided mapping paths.
|
|
||||||
- About screen showing app version, git commit, build date, and runtime/target platform.
|
|
||||||
- Release packaging scripts for Linux (`x86_64`, `aarch64`) and macOS arm64 outputs.
|
|
||||||
- macOS arm64 unsigned/ad-hoc `.pkg` packaging to `/opt/homebrew/bin/openbitdo`.
|
|
||||||
- AUR packaging sources for `openbitdo` and `openbitdo-bin`.
|
|
||||||
- Homebrew formula scaffolding and deferred tap sync script.
|
|
||||||
- Release workflow for tag-triggered GitHub pre-releases using changelog content.
|
|
||||||
- Release metadata rendering that computes authoritative checksums from published assets for AUR/Homebrew updates.
|
|
||||||
|
|
||||||
### Changed
|
- Beginner-first `openbitdo` launcher and terminal dashboard.
|
||||||
- Project license transitioned to BSD 3-Clause.
|
- Release packaging scripts for Linux and macOS artifacts.
|
||||||
- CI expanded to include macOS arm64 package build validation and AUR package metadata validation.
|
- Unsigned, non-notarized macOS `.pkg` output for RC distribution.
|
||||||
- Release process documentation updated for clean-tree requirements and RC gating policy.
|
- AUR and Homebrew release metadata rendering.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- Homebrew and AUR publication paths are automated and remain controlled by repo variables (`HOMEBREW_PUBLISH_ENABLED`, `AUR_PUBLISH_ENABLED`).
|
|
||||||
- Hardware CI gates remain required as configured in `ci.yml`.
|
- Historical RC notes are preserved here for audit continuity.
|
||||||
|
|||||||
80
MIGRATION.md
80
MIGRATION.md
@@ -1,54 +1,46 @@
|
|||||||
# OpenBitdo Migration Notes
|
# OpenBitdo Migration Notes
|
||||||
|
|
||||||
## What changed
|
This file explains the current user and contributor surface after the CLI and packaging cleanup.
|
||||||
- `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`.
|
|
||||||
|
|
||||||
## New usage
|
## Current CLI Contract
|
||||||
From `/Users/brooklyn/data/8bitdo/cleanroom/sdk`:
|
|
||||||
|
|
||||||
```bash
|
- `openbitdo` launches the interactive dashboard.
|
||||||
cargo run -p openbitdo --
|
- `openbitdo --mock` launches the dashboard without real hardware.
|
||||||
```
|
- Historical subcommand-style entry points are no longer part of the supported CLI.
|
||||||
|
|
||||||
Optional mock mode:
|
## Current Packaging Contract
|
||||||
|
|
||||||
```bash
|
- GitHub prereleases are the canonical release source.
|
||||||
cargo run -p openbitdo -- --mock
|
- AUR publishes `openbitdo-bin`.
|
||||||
```
|
- Homebrew publishes through the separate tap repo `bybrooklyn/homebrew-openbitdo`.
|
||||||
|
- macOS artifacts remain unsigned and non-notarized until Apple credentials exist.
|
||||||
|
|
||||||
## Beginner flow
|
## Current Settings Contract
|
||||||
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.
|
|
||||||
|
|
||||||
## Firmware behavior
|
Persisted UI state uses `schema_version = 2` with these fields:
|
||||||
- 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.
|
|
||||||
|
|
||||||
## New device-specific wizards
|
- `advanced_mode`
|
||||||
- JP108 (`0x5209`/`0x520a`):
|
- `report_save_mode`
|
||||||
- Dedicated button mapping for `A`, `B`, and `K1-K8`
|
- `device_filter_text`
|
||||||
- Auto-backup before write
|
- `dashboard_layout_mode`
|
||||||
- One-click restore if needed
|
- `last_panel_focus`
|
||||||
- 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
|
|
||||||
|
|
||||||
## CI changes
|
Compatibility behavior:
|
||||||
- Hardware CI split into per-family jobs:
|
|
||||||
- `hardware-dinput` (required)
|
- v1 settings still load with defaults for new fields.
|
||||||
- `hardware-standard64` (required)
|
- invalid settings files now raise a warning and fall back to defaults instead of being silently accepted.
|
||||||
- `hardware-ultimate2` (required)
|
|
||||||
- `hardware-108jp` (required)
|
## Current Library Contract
|
||||||
- `hardware-jphandshake` (gated until fixture availability)
|
|
||||||
|
OpenBitdo keeps headless automation as a Rust API, not a public CLI surface.
|
||||||
|
The supported entry points remain:
|
||||||
|
|
||||||
|
- `bitdo_tui::run_headless`
|
||||||
|
- `bitdo_tui::RunLaunchOptions`
|
||||||
|
- `bitdo_tui::HeadlessOutputMode`
|
||||||
|
|
||||||
|
## Practical Migration Guidance
|
||||||
|
|
||||||
|
- If you used the historical CLI subcommands, switch to `openbitdo` or `openbitdo --mock`.
|
||||||
|
- If you need automation, call the Rust API instead of expecting a supported headless CLI.
|
||||||
|
- If you document install paths, prefer Homebrew tap, AUR, tarball, or source build rather than old ad hoc command forms.
|
||||||
|
|||||||
168
RC_CHECKLIST.md
168
RC_CHECKLIST.md
@@ -1,42 +1,26 @@
|
|||||||
# OpenBitdo RC Checklist (`v0.0.1-rc.1`)
|
# OpenBitdo RC Checklist (`v0.0.1-rc.4`)
|
||||||
|
|
||||||
This checklist defines release-candidate readiness for the first public RC tag.
|
This checklist defines the release-candidate gate for the current public RC tag.
|
||||||
|
|
||||||
## Candidate Policy
|
## Release Policy
|
||||||
- Tag format: `v*` (for this RC: `v0.0.1-rc.1`)
|
|
||||||
|
- Tag format: `v*`
|
||||||
|
- Current RC tag: `v0.0.1-rc.4`
|
||||||
- Tag source: `main` only
|
- Tag source: `main` only
|
||||||
- Release trigger: tag push
|
- Release trigger: tag push
|
||||||
- RC gate: all required CI checks + manual smoke validation
|
- Public RC rule: zero open issues labeled `release-blocker`
|
||||||
|
|
||||||
## Release-Blocker Policy
|
## Required CI Checks
|
||||||
Use GitHub issue labels:
|
|
||||||
- `release-blocker`
|
|
||||||
- `severity:p0`
|
|
||||||
- `severity:p1`
|
|
||||||
- `severity:p2`
|
|
||||||
|
|
||||||
Public RC gate rule:
|
- `guard`
|
||||||
- zero open issues labeled `release-blocker`
|
- `aur-validate`
|
||||||
|
- `tui-smoke-test`
|
||||||
|
- `build-macos-arm64`
|
||||||
|
- `test`
|
||||||
|
|
||||||
Daily review cadence:
|
## Clean Tree Gate
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Scope-Completeness Gate ("Good Point")
|
From `cleanroom/`:
|
||||||
Before tagging `v0.0.1-rc.1`, 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`
|
|
||||||
- known controller-button targets
|
|
||||||
- profile slot read/write/readback
|
|
||||||
- firmware version in diagnostics/reports
|
|
||||||
- L2/R2 analog read (and write where capability allows)
|
|
||||||
|
|
||||||
Release gate is checklist-driven for RC (no separate scorecard artifact).
|
|
||||||
|
|
||||||
## Clean-Tree Requirement (Before Tagging)
|
|
||||||
Run from `/Users/brooklyn/data/8bitdo/cleanroom`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
@@ -44,103 +28,53 @@ git clean -ndX
|
|||||||
```
|
```
|
||||||
|
|
||||||
Expected:
|
Expected:
|
||||||
- `git status --porcelain` prints nothing (no modified, staged, or untracked files)
|
|
||||||
- `git clean -ndX` output reviewed for ignored-build artifacts only
|
|
||||||
|
|
||||||
Tracked-path audit:
|
- no tracked modifications or staged changes
|
||||||
|
- ignored-output review only from `git clean -ndX`
|
||||||
```bash
|
|
||||||
git ls-files | rg '(^sdk/dist/|^sdk/target/|^harness/reports/)'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- no tracked artifact/build-output paths matched
|
|
||||||
|
|
||||||
## Required CI Checks
|
|
||||||
- `guard`
|
|
||||||
- `aur-validate`
|
|
||||||
- `tui-smoke-test`
|
|
||||||
- `build-macos-arm64`
|
|
||||||
- `test`
|
|
||||||
|
|
||||||
## Release Secret Preflight (Tag Workflow)
|
|
||||||
Tag preflight must fail early if any required secret is missing:
|
|
||||||
- `AUR_USERNAME`
|
|
||||||
- `AUR_SSH_PRIVATE_KEY`
|
|
||||||
- `HOMEBREW_TAP_TOKEN`
|
|
||||||
|
|
||||||
## Artifact Expectations
|
## Artifact Expectations
|
||||||
|
|
||||||
Release assets must include:
|
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`
|
|
||||||
- corresponding `.sha256` files for every artifact above
|
|
||||||
|
|
||||||
## Verify Checksums
|
- `openbitdo-v0.0.1-rc.4-linux-x86_64.tar.gz`
|
||||||
Run from release asset directory:
|
- `openbitdo-v0.0.1-rc.4-linux-x86_64`
|
||||||
|
- `openbitdo-v0.0.1-rc.4-linux-aarch64.tar.gz`
|
||||||
|
- `openbitdo-v0.0.1-rc.4-linux-aarch64`
|
||||||
|
- `openbitdo-v0.0.1-rc.4-macos-arm64.tar.gz`
|
||||||
|
- `openbitdo-v0.0.1-rc.4-macos-arm64`
|
||||||
|
- `openbitdo-v0.0.1-rc.4-macos-arm64.pkg`
|
||||||
|
- `.sha256` files for every artifact above
|
||||||
|
|
||||||
```bash
|
## Distribution Gate
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Smoke Matrix
|
- GitHub prerelease assets must be published successfully.
|
||||||
1. Linux `x86_64`
|
- AUR publish must render checksum-pinned metadata and update `openbitdo-bin`.
|
||||||
- Extract tarball, run `./bin/openbitdo --mock`
|
- Homebrew publish must render a checksum-pinned formula and update `bybrooklyn/homebrew-openbitdo`.
|
||||||
- Confirm waiting/home flow renders
|
|
||||||
- Confirm About page opens (`a` and mouse click)
|
|
||||||
|
|
||||||
2. Linux `aarch64`
|
## macOS Packaging Gate
|
||||||
- Extract tarball, run `./bin/openbitdo --mock`
|
|
||||||
- Confirm main navigation and update preflight render
|
|
||||||
|
|
||||||
3. macOS arm64
|
- `.pkg` remains unsigned and non-notarized for this RC.
|
||||||
- Run standalone binary `openbitdo --mock`
|
- Gatekeeper friction is expected and must be documented.
|
||||||
- Install `.pkg`, then run `/opt/homebrew/bin/openbitdo --mock`
|
- Tarball and standalone binary remain the fallback paths.
|
||||||
- Confirm launch and About page behavior
|
|
||||||
|
|
||||||
## Distribution Readiness Notes
|
## Manual Smoke Expectations
|
||||||
- Homebrew publication runs after release asset publish when `HOMEBREW_PUBLISH_ENABLED=1`.
|
|
||||||
- AUR publication runs after release asset publish when `AUR_PUBLISH_ENABLED=1`.
|
1. Linux `x86_64`: launch `openbitdo --mock`
|
||||||
- Both package paths use release-derived SHA256 values (no `SKIP`, no `:no_check` in published metadata).
|
2. Linux `aarch64`: launch `openbitdo --mock`
|
||||||
|
3. macOS arm64 standalone binary: launch `openbitdo --mock`
|
||||||
|
4. macOS arm64 `.pkg`: confirm payload installation path and launch behavior where possible
|
||||||
|
|
||||||
|
## Current Status Snapshot
|
||||||
|
|
||||||
## RC Gate Snapshot (Local)
|
|
||||||
| Gate | Status | Notes |
|
| Gate | Status | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Clean tree | Pass | Verified empty on `c3115da` before final checklist update (`git status --porcelain`). |
|
| Required CI checks | Pass | Current required checks are configured in GitHub branch protection. |
|
||||||
| Secrets present | Pass | `AUR_USERNAME`, `AUR_SSH_PRIVATE_KEY`, `HOMEBREW_TAP_TOKEN` exist in repo secrets. |
|
| GitHub prerelease assets | Pending | Verify `v0.0.1-rc.4` assets after the tag workflow completes. |
|
||||||
| Required checks configured | Pass | `guard`, `test`, `tui-smoke-test`, `aur-validate`, `build-macos-arm64`. |
|
| AUR publication | Pending | Verify `openbitdo-bin` updates to `v0.0.1-rc.4` after release publication. |
|
||||||
| Open release-blocker issues | Pass | `0` open (`gh issue list --label release-blocker --state open`). |
|
| Homebrew publication | Pending | Verify `bybrooklyn/homebrew-openbitdo` updates to `v0.0.1-rc.4` after release publication. |
|
||||||
| RC release allowed | Fail | `No` yet: AUR SSH auth still returns `Permission denied (publickey)`. |
|
| macOS notarization | Deferred | Explicitly out of scope until Apple credentials exist. |
|
||||||
|
|
||||||
## RC Execution Log
|
## Historical Notes
|
||||||
- 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.
|
- Historical RC activity for earlier candidates is preserved in commit history and the changelog.
|
||||||
- 2026-03-02T21:40:00Z: bootstrapped tap repo `bybrooklyn/homebrew-openbitdo` with initial `Formula/openbitdo.rb`.
|
- Troubleshooting for AUR SSH publication lives in `process/aur_publish_troubleshooting.md`.
|
||||||
- 2026-03-02T21:45:27Z to 2026-03-02T21:48:55Z: CI run `22597105846` on commit `c3115da` passed `guard`, `test`, `tui-smoke-test`, `aur-validate`, `build-macos-arm64`, `build-linux-x86_64`, and `build-linux-aarch64`.
|
|
||||||
- 2026-03-02T21:49:00Z to 2026-03-02T21:55:00Z: downloaded CI artifacts and manually verified each artifact hash against `.sha256` content (all matched) for:
|
|
||||||
- `openbitdo-v0.0.0-ci-linux-x86_64.tar.gz`
|
|
||||||
- `openbitdo-v0.0.0-ci-linux-x86_64`
|
|
||||||
- `openbitdo-v0.0.0-ci-linux-aarch64.tar.gz`
|
|
||||||
- `openbitdo-v0.0.0-ci-linux-aarch64`
|
|
||||||
- `openbitdo-v0.0.0-ci-macos-arm64.tar.gz`
|
|
||||||
- `openbitdo-v0.0.0-ci-macos-arm64`
|
|
||||||
- `openbitdo-v0.0.0-ci-macos-arm64.pkg`
|
|
||||||
- 2026-03-02T21:56:00Z to 2026-03-02T22:00:00Z: Linux artifact smoke completed in containers for `linux/amd64` and `linux/arm64` by launching `openbitdo --mock` and observing successful TUI startup.
|
|
||||||
- 2026-03-02T21:57:00Z: local macOS packaging smoke completed via `./sdk/scripts/package-macos.sh v0.0.0-local arm64` (tarball, standalone binary, pkg generated).
|
|
||||||
- 2026-03-02T21:58:00Z: local standalone macOS smoke completed (`openbitdo-v0.0.0-local-macos-arm64 --mock`) with TUI startup and clean exit via scripted key input.
|
|
||||||
- 2026-03-02T21:59:00Z: pkg payload path validated by expansion (`Payload/opt/homebrew/bin/openbitdo`); direct installer invocation requires root (`installer: Must be run as root to install this package`).
|
|
||||||
- 2026-03-02T21:59:30Z: About behavior validated by test run `cargo test -p bitdo_tui about_state_roundtrip_returns_home`.
|
|
||||||
- 2026-03-02T22:02:00Z: Wave 2 issues `#2` through `#13` closed with per-issue evidence comments.
|
|
||||||
- 2026-03-02T22:03:00Z: epic issue `#1` closed and `release-blocker` label removed after child closure summary.
|
|
||||||
- 2026-03-02T22:04:00Z: clean-tree gate confirmed on baseline commit `c3115da` (`git status --porcelain` empty).
|
|
||||||
- 2026-03-02T22:10:00Z: removed hardware self-hosted checks from branch protection and CI/release gate policy.
|
|
||||||
|
|||||||
212
README.md
212
README.md
@@ -1,186 +1,62 @@
|
|||||||
# OpenBitdo
|
# OpenBitdo
|
||||||
|
|
||||||
OpenBitdo is a clean-room implementation workspace for beginner-friendly 8BitDo tooling.
|
OpenBitdo is a clean-room, beginner-first 8BitDo utility built around a modern terminal UI.
|
||||||
OpenBitDo is an unofficial tool and should be used at your own risk. The authors are not responsible for device damage, firmware corruption, or data loss. This project is not affiliated with or endorsed by 8BitDo. See the [LICENSE](/Users/brooklyn/data/8bitdo/cleanroom/LICENSE) file for details.
|
It focuses on safe diagnostics first, guided firmware/update flows for confirmed devices, and clear blocked-action messaging for devices that are still under investigation.
|
||||||
|
|
||||||
## Beginner Quickstart
|
OpenBitdo is unofficial and not affiliated with 8BitDo. Firmware updates and device changes always carry risk. Read the prompts carefully and keep backups or recovery plans where available.
|
||||||
From `/Users/brooklyn/data/8bitdo/cleanroom/sdk`:
|
|
||||||
|
|
||||||
```bash
|
## What OpenBitdo Does Today
|
||||||
cargo run -p openbitdo --
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional mock mode:
|
- Detect connected 8BitDo devices and explain their current support level.
|
||||||
|
- Run diagnostics and save support reports.
|
||||||
|
- Stage verified firmware updates on full-support devices.
|
||||||
|
- Edit supported mappings for the currently confirmed JP108 and Ultimate 2 flows.
|
||||||
|
- Keep unconfirmed devices in safe read-only or detect-only paths.
|
||||||
|
|
||||||
```bash
|
## Install
|
||||||
cargo run -p openbitdo -- --mock
|
|
||||||
```
|
|
||||||
|
|
||||||
Beginner flow is always `openbitdo` only. No extra command surface is required.
|
| Path | Command | Best for |
|
||||||
|
|
||||||
## Install (Public RC)
|
|
||||||
|
|
||||||
Homebrew tap install path for RC:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap bybrooklyn/openbitdo
|
|
||||||
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`.
|
|
||||||
- notarization is required starting with `v0.1.0`.
|
|
||||||
|
|
||||||
## UI Language Support
|
|
||||||
|
|
||||||
| Language | Status | Notes |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| English | Supported | Default and primary beginner flow language. |
|
| Homebrew | `brew tap bybrooklyn/openbitdo && brew install openbitdo` | macOS or Linux users who want the standard tap flow |
|
||||||
| Spanish | In Progress | Roadmap item for core wizard screens. |
|
| AUR | `paru -S openbitdo-bin` | Arch Linux users who want a prebuilt package |
|
||||||
| Japanese | Planned | Planned after Spanish stabilization. |
|
| GitHub Releases | Download a release tarball and run `bin/openbitdo` | Users who want a standalone binary without a package manager |
|
||||||
| German | Planned | Planned after Spanish stabilization. |
|
| From source | `cargo run -p openbitdo --` from `sdk/` | Contributors and local development |
|
||||||
| French | Planned | Planned after Spanish stabilization. |
|
|
||||||
|
|
||||||
## Support Status Model
|
## First Run
|
||||||
|
|
||||||
| Runtime Tier | README Status | Meaning |
|
1. Launch `openbitdo`.
|
||||||
| --- | --- | --- |
|
2. If you do not have hardware attached yet, launch `openbitdo --mock` to preview the interface.
|
||||||
| `full` | Supported | Mapping/update paths allowed when safety gates are satisfied. |
|
3. Pick a controller from the dashboard.
|
||||||
| `candidate-readonly` | In Progress | Detect/identify/diagnostics enabled, writes and firmware transfer blocked. |
|
4. Use `Diagnose` first if you are unsure what is safe for that device.
|
||||||
| `detect-only` | Planned | Detect/identify baseline now, broader functionality planned. |
|
5. Follow the support-tier guidance shown in the UI before attempting update or mapping work.
|
||||||
|
|
||||||
Beginner UI note: candidate devices are shown with blocked-action messaging in the TUI so new users always see the safe path first.
|
## Support Tiers
|
||||||
|
|
||||||
## Protocol Family Overview
|
| Tier | What it means |
|
||||||
|
| --- | --- |
|
||||||
|
| `supported` | Diagnostics, update, and any confirmed mapping flows are available when safety gates are satisfied. |
|
||||||
|
| `read-only candidate` | Diagnostics are available, but update and mapping stay blocked until runtime and hardware confirmation are complete. |
|
||||||
|
| `detect-only` | OpenBitdo can identify the device, but deeper workflows are intentionally unavailable. |
|
||||||
|
|
||||||
| Family | PID Rows | Notes |
|
## macOS Packaging Caveat
|
||||||
| --- | --- | --- |
|
|
||||||
| Standard64 | 36 | Largest family, mixed status from Planned to Supported. |
|
|
||||||
| DInput | 11 | Includes current full-support Ultimate2 and Pro3 lines. |
|
|
||||||
| JpHandshake | 9 | Includes JP108 full support and keyboard-related candidate lines. |
|
|
||||||
| DS4Boot | 0 | No standalone canonical PID rows in current catalog. |
|
|
||||||
| Unknown | 1 | Sentinel/internal row only. |
|
|
||||||
|
|
||||||
DS4Boot explicit note: there are currently no active canonical PID rows in this catalog.
|
Current macOS release artifacts are unsigned and non-notarized.
|
||||||
|
That means Gatekeeper friction is expected for the `.pkg`, tarball, and standalone binary.
|
||||||
|
If the installer path is inconvenient, use the extracted tarball or Homebrew tap as the fallback path.
|
||||||
|
Apple Developer signing and notarization are deferred until credentials are available.
|
||||||
|
|
||||||
## Compatibility Verification Dates (Manual, ISO)
|
## Release And Package Map
|
||||||
| Family | Last verified date | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Standard64 | 2026-03-02 | Manual RC verification date. |
|
|
||||||
| DInput | 2026-03-02 | Manual RC verification date. |
|
|
||||||
| JpHandshake | 2026-03-02 | Manual RC verification date. |
|
|
||||||
| DS4Boot | 2026-03-02 | No active canonical rows. |
|
|
||||||
| Unknown | 2026-03-02 | Sentinel/internal row only. |
|
|
||||||
|
|
||||||
## Device Compatibility (Canonical, No Duplicate PIDs)
|
- GitHub prereleases publish the canonical release assets.
|
||||||
Device naming references are documented in [device_name_catalog.md](/Users/brooklyn/data/8bitdo/cleanroom/spec/device_name_catalog.md) and [device_name_sources.md](/Users/brooklyn/data/8bitdo/cleanroom/process/device_name_sources.md).
|
- AUR publishes `openbitdo-bin` from the Linux tarballs and release-derived checksums.
|
||||||
|
- Homebrew publishes through the separate tap repo `bybrooklyn/homebrew-openbitdo`.
|
||||||
|
- Package-manager metadata is rendered from published assets so release checksums stay authoritative.
|
||||||
|
|
||||||
### Unknown
|
## Where To Go Next
|
||||||
|
|
||||||
| Canonical ID | Display Name | PID (hex) | Family | Status | Current User Actions | Firmware Path | Notes |
|
- Developer and release-engineering details: [sdk/README.md](sdk/README.md)
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
- Current RC release gate: [RC_CHECKLIST.md](RC_CHECKLIST.md)
|
||||||
| `PID_None` | No Device (Sentinel) | `0x0000` | Unknown | Planned | None | N/A | Internal sentinel row only. |
|
- Changelog and release notes: [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
- CLI and packaging migration notes: [MIGRATION.md](MIGRATION.md)
|
||||||
### Standard64
|
- Device catalog: [spec/device_name_catalog.md](spec/device_name_catalog.md)
|
||||||
|
- Protocol overview: [spec/protocol_spec.md](spec/protocol_spec.md)
|
||||||
| Canonical ID | Display Name | PID (hex) | Family | Status | Current User Actions | Firmware Path | Notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `PID_IDLE` | Unconfirmed Internal Device (PID_IDLE) | `0x3109` | Standard64 | Planned | Detect, identify | Blocked | Internal interface row. |
|
|
||||||
| `PID_SN30Plus` | SN30 Pro+ | `0x6002` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_USB_Ultimate` | Unconfirmed Internal Device (PID_USB_Ultimate) | `0x3100` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_USB_Ultimate2` | Unconfirmed Internal Device (PID_USB_Ultimate2) | `0x3105` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_USB_UltimateClasses` | Unconfirmed Internal Device (PID_USB_UltimateClasses) | `0x3104` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_Xcloud` | Unconfirmed Internal Device (PID_Xcloud) | `0x2100` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Xcloud2` | Unconfirmed Internal Device (PID_Xcloud2) | `0x2101` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_ArcadeStick` | Arcade Stick | `0x901a` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Pro2` | Pro 2 Bluetooth Controller | `0x6003` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Pro2_CY` | Unconfirmed Variant Name (PID_Pro2_CY) | `0x6006` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Pro2_Wired` | Pro 2 Wired Controller | `0x3010` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Ultimate_PC` | Ultimate PC Controller | `0x3011` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Ultimate2_4` | Ultimate 2.4G Controller | `0x3012` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Ultimate2_4RR` | Ultimate 2.4G Adapter | `0x3013` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_UltimateBT` | Ultimate Wireless Controller | `0x6007` | Standard64 | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_UltimateBTRR` | Ultimate Wireless Adapter | `0x3106` | Standard64 | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_NUMPAD` | Retro 18 Mechanical Numpad | `0x5203` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_NUMPADRR` | Retro 18 Adapter | `0x5204` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Pro3DOCK` | Charging Dock for Pro 3S Gamepad | `0x600d` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_NGCDIY` | Mod Kit for NGC Controller | `0x5750` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_NGCRR` | Retro Receiver for NGC | `0x902a` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_Mouse` | Retro R8 Mouse | `0x5205` | Standard64 | Planned | Detect, identify | Blocked | Canonical row for `0x5205`. |
|
|
||||||
| `PID_MouseRR` | Retro R8 Adapter | `0x5206` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_SaturnRR` | Retro Receiver for Saturn | `0x902b` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_UltimateBT2C` | Ultimate 2C Bluetooth Controller | `0x301a` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_Lashen` | Ultimate Mobile Gaming Controller | `0x301e` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_N64BT` | 64 Bluetooth Controller | `0x3019` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_N64` | 64 2.4G Wireless Controller | `0x3004` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_N64RR` | Retro Receiver for N64 | `0x9028` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_XBOXUK` | Retro 87 Mechanical Keyboard - Xbox (UK) | `0x3026` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_XBOXUKUSB` | Retro 87 Mechanical Keyboard Adapter - Xbox (UK) | `0x3027` | Standard64 | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_LashenX` | Ultimate Mobile Gaming Controller For Xbox | `0x200b` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_N64JoySticks` | Joystick v2 for N64 Controller | `0x3021` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_DoubleSuper` | Wireless Dual Super Button | `0x203e` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_Cube2RR` | Retro Cube 2 Adapter - N Edition | `0x2056` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_Cube2` | Retro Cube 2 Speaker - N Edition | `0x2039` | Standard64 | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
|
|
||||||
### JpHandshake
|
|
||||||
|
|
||||||
| Canonical ID | Display Name | PID (hex) | Family | Status | Current User Actions | Firmware Path | Notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `PID_JP` | Retro Mechanical Keyboard | `0x5200` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_JPUSB` | Retro Mechanical Keyboard Receiver | `0x5201` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_108JP` | Retro 108 Mechanical Keyboard | `0x5209` | JpHandshake | Supported | Detect, diagnose, dedicated mapping (`A/B/K1-K8`), recommended update | Stable download + local fallback | Full-support JP108 flow. |
|
|
||||||
| `PID_108JPUSB` | Retro 108 Mechanical Adapter | `0x520a` | JpHandshake | Supported | Detect, diagnose, dedicated mapping (`A/B/K1-K8`), recommended update | Stable download + local fallback | Full-support JP108 receiver flow. |
|
|
||||||
| `PID_XBOXJP` | Retro 87 Mechanical Keyboard - Xbox Edition | `0x2028` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_XBOXJPUSB` | Retro 87 Mechanical Keyboard Adapter - Xbox Edition | `0x202e` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_68JP` | Retro 68 Keyboard - N40 | `0x203a` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_68JPUSB` | Retro 68 Keyboard Adapter - N40 | `0x2049` | JpHandshake | In Progress | Detect, identify, diagnose | Blocked (`NotHardwareConfirmed`) | Candidate read-only. |
|
|
||||||
| `PID_ASLGJP` | Riviera Keyboard | `0x205a` | JpHandshake | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
|
|
||||||
### DInput
|
|
||||||
|
|
||||||
| Canonical ID | Display Name | PID (hex) | Family | Status | Current User Actions | Firmware Path | Notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `PID_QINGCHUN2` | Ultimate 2C Controller | `0x310a` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_QINGCHUN2RR` | Ultimate 2C Wireless Adapter | `0x301c` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_Xinput` | Unconfirmed Interface Name (PID_Xinput) | `0x310b` | DInput | Planned | Detect, identify | Blocked | Planned support only. |
|
|
||||||
| `PID_Pro3` | Pro 3 Bluetooth Gamepad | `0x6009` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_Pro3USB` | Pro 3 Bluetooth Adapter | `0x600a` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_Ultimate2` | Ultimate 2 Wireless Controller | `0x6012` | DInput | Supported | Detect, diagnose, slot mapping (`A/B/K1-K8`), analog L2/R2, recommended update | Stable download + local fallback | Full-support Ultimate2 RC scope (known controller-button targets). |
|
|
||||||
| `PID_Ultimate2RR` | Ultimate 2 Wireless Adapter | `0x6013` | DInput | Supported | Detect, diagnose, slot mapping (`A/B/K1-K8`), analog L2/R2, recommended update | Stable download + local fallback | Full-support Ultimate2 receiver RC scope (known controller-button targets). |
|
|
||||||
| `PID_UltimateBT2` | Ultimate 2 Bluetooth Controller | `0x600f` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_UltimateBT2RR` | Ultimate 2 Bluetooth Adapter | `0x6011` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_HitBox` | Arcade Controller | `0x600b` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
| `PID_HitBoxRR` | Arcade Controller Adapter | `0x600c` | DInput | Supported | Detect, diagnose, recommended update | Stable download + local fallback | Full-support runtime. |
|
|
||||||
|
|
||||||
## Alias Appendix (Non-Canonical Names)
|
|
||||||
These aliases are intentionally excluded from canonical PID rows to guarantee uniqueness.
|
|
||||||
|
|
||||||
| Alias PID Name | Canonical PID Name | PID (hex) |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `PID_Pro2_OLD` | `PID_Pro2` | `0x6003` |
|
|
||||||
| `PID_ASLGMouse` | `PID_Mouse` | `0x5205` |
|
|
||||||
|
|
||||||
See [alias_index.md](/Users/brooklyn/data/8bitdo/cleanroom/spec/alias_index.md) for details.
|
|
||||||
|
|
||||||
## Hardware Support Confidence
|
|
||||||
Support is implemented to our best current knowledge. Coverage and confidence are expanded and confirmed over time through community testing and hardware reports.
|
|
||||||
|
|
||||||
## Dirty-Room Evidence Backlog
|
|
||||||
- [dirtyroom_evidence_backlog.md](/Users/brooklyn/data/8bitdo/cleanroom/process/dirtyroom_evidence_backlog.md)
|
|
||||||
- [dirtyroom_collection_playbook.md](/Users/brooklyn/data/8bitdo/cleanroom/process/dirtyroom_collection_playbook.md)
|
|
||||||
- [dirtyroom_dossier_schema.md](/Users/brooklyn/data/8bitdo/cleanroom/process/dirtyroom_dossier_schema.md)
|
|
||||||
- [community_evidence_intake.md](/Users/brooklyn/data/8bitdo/cleanroom/process/community_evidence_intake.md)
|
|
||||||
|
|
||||||
## Source Notes
|
|
||||||
- Canonical clean-room naming catalog: [device_name_catalog.md](/Users/brooklyn/data/8bitdo/cleanroom/spec/device_name_catalog.md)
|
|
||||||
- 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.
|
|
||||||
RC gating is checklist-based (see [RC_CHECKLIST.md](/Users/brooklyn/data/8bitdo/cleanroom/RC_CHECKLIST.md)).
|
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
# AUR Packaging
|
# AUR Packaging
|
||||||
|
|
||||||
This directory contains AUR package sources for:
|
This directory holds the tracked AUR source for `openbitdo-bin`.
|
||||||
- `openbitdo` (source build)
|
|
||||||
- `openbitdo-bin` (prebuilt release assets)
|
|
||||||
|
|
||||||
Publishing is automated by `.github/workflows/aur-publish.yml` and remains gated:
|
## Source Of Truth
|
||||||
- requires repository variable `AUR_PUBLISH_ENABLED=1`
|
|
||||||
- requires secrets `AUR_SSH_PRIVATE_KEY` and `AUR_USERNAME`
|
|
||||||
|
|
||||||
Publish flow:
|
- tracked metadata: `packaging/aur/openbitdo-bin/PKGBUILD` and `.SRCINFO`
|
||||||
1. wait for release assets from a `v*` tag
|
- template: `packaging/aur/openbitdo-bin/PKGBUILD.tmpl`
|
||||||
2. compute authoritative SHA256 values from released artifacts
|
- renderer: `packaging/scripts/render_release_metadata.sh`
|
||||||
3. render `PKGBUILD`/`.SRCINFO` with pinned hashes
|
|
||||||
4. push updates to AUR repos
|
|
||||||
|
|
||||||
Template files used for release rendering:
|
## Publish Flow
|
||||||
- `openbitdo/PKGBUILD.tmpl`
|
|
||||||
- `openbitdo-bin/PKGBUILD.tmpl`
|
1. Publish GitHub release assets for a `v*` tag.
|
||||||
|
2. Render `PKGBUILD` and `.SRCINFO` from those assets.
|
||||||
|
3. Upload rendered metadata as a workflow artifact for audit.
|
||||||
|
4. Push the updated metadata to the AUR repo for `openbitdo-bin`.
|
||||||
|
|
||||||
|
## Required Controls
|
||||||
|
|
||||||
|
- repo variable `AUR_PUBLISH_ENABLED=1`
|
||||||
|
- secrets `AUR_USERNAME` and `AUR_SSH_PRIVATE_KEY`
|
||||||
|
- no placeholder checksums in published metadata
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
pkgbase = openbitdo-bin
|
pkgbase = openbitdo-bin
|
||||||
pkgdesc = Prebuilt beginner-first clean-room 8BitDo utility
|
pkgdesc = Prebuilt beginner-first clean-room 8BitDo utility
|
||||||
pkgver = 0.0.1rc1
|
pkgver = 0.0.1rc3
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/bybrooklyn/openbitdo
|
url = https://github.com/bybrooklyn/openbitdo
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
arch = aarch64
|
arch = aarch64
|
||||||
license = BSD-3-Clause
|
license = BSD-3-Clause
|
||||||
depends = hidapi
|
depends = hidapi
|
||||||
source_x86_64 = openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz::https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.1/openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz
|
source_x86_64 = openbitdo-v0.0.1-rc.3-linux-x86_64.tar.gz::https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.3/openbitdo-v0.0.1-rc.3-linux-x86_64.tar.gz
|
||||||
sha256sums_x86_64 = 0000000000000000000000000000000000000000000000000000000000000000
|
sha256sums_x86_64 = fa7ae583796dc979a64eaeb670a06fc7c10fd51bbb12fce5815d66629e18c9a0
|
||||||
source_aarch64 = openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz::https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.1/openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz
|
source_aarch64 = openbitdo-v0.0.1-rc.3-linux-aarch64.tar.gz::https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.3/openbitdo-v0.0.1-rc.3-linux-aarch64.tar.gz
|
||||||
sha256sums_aarch64 = 0000000000000000000000000000000000000000000000000000000000000000
|
sha256sums_aarch64 = 992586ff89da6dfdb137fd424f651ccedb4a65de0f43d615cda682e31e222dd0
|
||||||
|
|
||||||
pkgname = openbitdo-bin
|
pkgname = openbitdo-bin
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgname=openbitdo-bin
|
pkgname=openbitdo-bin
|
||||||
pkgver=0.0.1rc1
|
pkgver=0.0.1rc3
|
||||||
_upstream_tag=v0.0.1-rc.1
|
_upstream_tag=v0.0.1-rc.3
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Prebuilt beginner-first clean-room 8BitDo utility"
|
pkgdesc="Prebuilt beginner-first clean-room 8BitDo utility"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
@@ -9,8 +9,8 @@ license=('BSD-3-Clause')
|
|||||||
depends=('hidapi')
|
depends=('hidapi')
|
||||||
source_x86_64=("openbitdo-${_upstream_tag}-linux-x86_64.tar.gz::${url}/releases/download/${_upstream_tag}/openbitdo-${_upstream_tag}-linux-x86_64.tar.gz")
|
source_x86_64=("openbitdo-${_upstream_tag}-linux-x86_64.tar.gz::${url}/releases/download/${_upstream_tag}/openbitdo-${_upstream_tag}-linux-x86_64.tar.gz")
|
||||||
source_aarch64=("openbitdo-${_upstream_tag}-linux-aarch64.tar.gz::${url}/releases/download/${_upstream_tag}/openbitdo-${_upstream_tag}-linux-aarch64.tar.gz")
|
source_aarch64=("openbitdo-${_upstream_tag}-linux-aarch64.tar.gz::${url}/releases/download/${_upstream_tag}/openbitdo-${_upstream_tag}-linux-aarch64.tar.gz")
|
||||||
sha256sums_x86_64=('0000000000000000000000000000000000000000000000000000000000000000')
|
sha256sums_x86_64=('fa7ae583796dc979a64eaeb670a06fc7c10fd51bbb12fce5815d66629e18c9a0')
|
||||||
sha256sums_aarch64=('0000000000000000000000000000000000000000000000000000000000000000')
|
sha256sums_aarch64=('992586ff89da6dfdb137fd424f651ccedb4a65de0f43d615cda682e31e222dd0')
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
local extracted_dir
|
local extracted_dir
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
class Openbitdo < Formula
|
|
||||||
desc "Beginner-first clean-room 8BitDo TUI utility"
|
|
||||||
homepage "https://github.com/bybrooklyn/openbitdo"
|
|
||||||
license "BSD-3-Clause"
|
|
||||||
version "0.0.1-rc.1"
|
|
||||||
|
|
||||||
on_macos do
|
|
||||||
if Hardware::CPU.arm?
|
|
||||||
url "https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.1/openbitdo-v0.0.1-rc.1-macos-arm64.tar.gz"
|
|
||||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
on_linux do
|
|
||||||
if Hardware::CPU.intel?
|
|
||||||
url "https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.1/openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz"
|
|
||||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
elsif Hardware::CPU.arm?
|
|
||||||
url "https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.1/openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz"
|
|
||||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Release automation rewrites checksums in the tap with authoritative values
|
|
||||||
# from published assets.
|
|
||||||
def install
|
|
||||||
bin.install "bin/openbitdo"
|
|
||||||
end
|
|
||||||
|
|
||||||
test do
|
|
||||||
assert_match "openbitdo", shell_output("#{bin}/openbitdo --help")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
# Homebrew Packaging
|
# Homebrew Packaging
|
||||||
|
|
||||||
Formula source lives in `Formula/openbitdo.rb`.
|
Homebrew publishing uses the separate tap repo `bybrooklyn/homebrew-openbitdo`.
|
||||||
Template source used for release rendering: `Formula/openbitdo.rb.tmpl`.
|
|
||||||
|
|
||||||
Planned tap:
|
## Source Of Truth
|
||||||
- `bybrooklyn/homebrew-openbitdo`
|
|
||||||
|
|
||||||
Current status:
|
- template: `packaging/homebrew/Formula/openbitdo.rb.tmpl`
|
||||||
- release workflow computes checksum-pinned formula values from published assets
|
- renderer: `packaging/scripts/render_release_metadata.sh`
|
||||||
- tap sync remains gated by `HOMEBREW_PUBLISH_ENABLED=1`
|
- sync helper: `packaging/homebrew/sync_tap.sh`
|
||||||
|
|
||||||
Optional sync helper:
|
The main repo does not keep a checked-in rendered formula. Release rendering produces the formula from published assets, and the tap repo is the canonical published destination.
|
||||||
- `sync_tap.sh` (disabled by default unless `HOMEBREW_PUBLISH_ENABLED=1`)
|
|
||||||
|
## Publish Flow
|
||||||
|
|
||||||
|
1. Publish GitHub release assets for a `v*` tag.
|
||||||
|
2. Render a checksum-pinned formula from those assets.
|
||||||
|
3. Upload the rendered formula as a workflow artifact for audit.
|
||||||
|
4. Sync the rendered formula to `bybrooklyn/homebrew-openbitdo`.
|
||||||
|
|
||||||
|
## Required Controls
|
||||||
|
|
||||||
|
- repo variable `HOMEBREW_PUBLISH_ENABLED=1`
|
||||||
|
- repo variable `HOMEBREW_TAP_REPO=bybrooklyn/homebrew-openbitdo`
|
||||||
|
- secret `HOMEBREW_TAP_TOKEN`
|
||||||
|
|||||||
@@ -23,9 +23,22 @@ HOMEBREW_TAP_TOKEN="$(
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
TAP_REPO="${HOMEBREW_TAP_REPO:-bybrooklyn/homebrew-openbitdo}"
|
TAP_REPO="${HOMEBREW_TAP_REPO:-bybrooklyn/homebrew-openbitdo}"
|
||||||
FORMULA_SOURCE="${FORMULA_SOURCE:-$ROOT/packaging/homebrew/Formula/openbitdo.rb}"
|
FORMULA_SOURCE="${FORMULA_SOURCE:-}"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
|
|
||||||
|
decode_base64() {
|
||||||
|
if base64 --help 2>/dev/null | grep -q -- "--decode"; then
|
||||||
|
base64 --decode
|
||||||
|
else
|
||||||
|
base64 -D
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$FORMULA_SOURCE" ]]; then
|
||||||
|
echo "FORMULA_SOURCE must point to a rendered formula; run render_release_metadata.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$FORMULA_SOURCE" ]]; then
|
if [[ ! -f "$FORMULA_SOURCE" ]]; then
|
||||||
echo "formula source not found: $FORMULA_SOURCE" >&2
|
echo "formula source not found: $FORMULA_SOURCE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -69,7 +82,7 @@ remote_content_file="$TMP/remote_formula.rb"
|
|||||||
|
|
||||||
if api_with_fallback "repos/${TAP_REPO}/contents/${formula_path}?ref=main" >"$TMP/remote.json" 2>/dev/null; then
|
if api_with_fallback "repos/${TAP_REPO}/contents/${formula_path}?ref=main" >"$TMP/remote.json" 2>/dev/null; then
|
||||||
remote_sha="$(jq -r '.sha // ""' "$TMP/remote.json")"
|
remote_sha="$(jq -r '.sha // ""' "$TMP/remote.json")"
|
||||||
jq -r '.content // ""' "$TMP/remote.json" | tr -d '\n' | base64 --decode >"$remote_content_file"
|
jq -r '.content // ""' "$TMP/remote.json" | tr -d '\n' | decode_base64 >"$remote_content_file"
|
||||||
if cmp -s "$FORMULA_SOURCE" "$remote_content_file"; then
|
if cmp -s "$FORMULA_SOURCE" "$remote_content_file"; then
|
||||||
echo "no formula changes to push"
|
echo "no formula changes to push"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
98
packaging/homebrew/test_sync_tap.sh
Executable file
98
packaging/homebrew/test_sync_tap.sh
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
MOCK_BIN="$TMP/bin"
|
||||||
|
mkdir -p "$MOCK_BIN"
|
||||||
|
|
||||||
|
cat >"$MOCK_BIN/gh" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
STATE_DIR="${MOCK_GH_STATE_DIR:?}"
|
||||||
|
|
||||||
|
if [[ "${1:-}" != "api" ]]; then
|
||||||
|
echo "mock gh only supports api" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
|
||||||
|
method="GET"
|
||||||
|
if [[ "${1:-}" == "--method" ]]; then
|
||||||
|
method="$2"
|
||||||
|
shift 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
endpoint="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "${method}:${endpoint}" in
|
||||||
|
GET:repos/*/contents/Formula/openbitdo.rb\?ref=main)
|
||||||
|
if [[ -f "$STATE_DIR/remote_formula.rb" ]]; then
|
||||||
|
content="$(base64 < "$STATE_DIR/remote_formula.rb" | tr -d '\n')"
|
||||||
|
printf '{"sha":"remote-sha","content":"%s"}\n' "$content"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
PUT:repos/*/contents/Formula/openbitdo.rb)
|
||||||
|
content=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
if [[ "$1" == "-f" ]]; then
|
||||||
|
case "$2" in
|
||||||
|
content=*)
|
||||||
|
content="${2#content=}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s' "$content" | base64 --decode >"$STATE_DIR/updated_formula.rb"
|
||||||
|
echo "put" >>"$STATE_DIR/requests.log"
|
||||||
|
printf '{"content":{"sha":"new-sha"}}\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unexpected mock gh call: ${method} ${endpoint}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$MOCK_BIN/gh"
|
||||||
|
|
||||||
|
FORMULA_SOURCE="$TMP/openbitdo.rb"
|
||||||
|
cat >"$FORMULA_SOURCE" <<'EOF'
|
||||||
|
class Openbitdo < Formula
|
||||||
|
desc "OpenBitdo"
|
||||||
|
end
|
||||||
|
EOF
|
||||||
|
|
||||||
|
run_sync() {
|
||||||
|
PATH="$MOCK_BIN:$PATH" \
|
||||||
|
MOCK_GH_STATE_DIR="$TMP/mock-state" \
|
||||||
|
GH_TOKEN="job-token" \
|
||||||
|
HOMEBREW_TAP_TOKEN="tap-token" \
|
||||||
|
HOMEBREW_TAP_REPO="bybrooklyn/homebrew-openbitdo" \
|
||||||
|
HOMEBREW_PUBLISH_ENABLED="1" \
|
||||||
|
FORMULA_SOURCE="$FORMULA_SOURCE" \
|
||||||
|
bash "$ROOT/packaging/homebrew/sync_tap.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$TMP/mock-state"
|
||||||
|
cp "$FORMULA_SOURCE" "$TMP/mock-state/remote_formula.rb"
|
||||||
|
run_sync >"$TMP/noop.out"
|
||||||
|
grep -Fq "no formula changes to push" "$TMP/noop.out"
|
||||||
|
test ! -f "$TMP/mock-state/updated_formula.rb"
|
||||||
|
|
||||||
|
cat >"$TMP/mock-state/remote_formula.rb" <<'EOF'
|
||||||
|
class Openbitdo < Formula
|
||||||
|
desc "Old formula"
|
||||||
|
end
|
||||||
|
EOF
|
||||||
|
run_sync >"$TMP/update.out"
|
||||||
|
grep -Fq "updated bybrooklyn/homebrew-openbitdo:Formula/openbitdo.rb" "$TMP/update.out"
|
||||||
|
cmp -s "$FORMULA_SOURCE" "$TMP/mock-state/updated_formula.rb"
|
||||||
@@ -7,7 +7,6 @@ Usage:
|
|||||||
render_release_metadata.sh <tag> <repository> <input_dir> <output_dir>
|
render_release_metadata.sh <tag> <repository> <input_dir> <output_dir>
|
||||||
|
|
||||||
Inputs expected in <input_dir>:
|
Inputs expected in <input_dir>:
|
||||||
openbitdo-<tag>-source.tar.gz
|
|
||||||
openbitdo-<tag>-linux-x86_64.tar.gz
|
openbitdo-<tag>-linux-x86_64.tar.gz
|
||||||
openbitdo-<tag>-linux-aarch64.tar.gz
|
openbitdo-<tag>-linux-aarch64.tar.gz
|
||||||
openbitdo-<tag>-macos-arm64.tar.gz
|
openbitdo-<tag>-macos-arm64.tar.gz
|
||||||
@@ -46,13 +45,11 @@ aur_pkgver_from_tag() {
|
|||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
AUR_PKGVER="$(aur_pkgver_from_tag "$TAG")"
|
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_X86_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-x86_64.tar.gz"
|
||||||
LINUX_AARCH64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-aarch64.tar.gz"
|
LINUX_AARCH64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-aarch64.tar.gz"
|
||||||
MACOS_ARM64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-macos-arm64.tar.gz"
|
MACOS_ARM64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-macos-arm64.tar.gz"
|
||||||
|
|
||||||
for required in \
|
for required in \
|
||||||
"$SOURCE_ARCHIVE" \
|
|
||||||
"$LINUX_X86_ARCHIVE" \
|
"$LINUX_X86_ARCHIVE" \
|
||||||
"$LINUX_AARCH64_ARCHIVE" \
|
"$LINUX_AARCH64_ARCHIVE" \
|
||||||
"$MACOS_ARM64_ARCHIVE"; do
|
"$MACOS_ARM64_ARCHIVE"; do
|
||||||
@@ -62,13 +59,11 @@ for required in \
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
SOURCE_SHA256="$(sha256 "$SOURCE_ARCHIVE")"
|
|
||||||
LINUX_X86_SHA256="$(sha256 "$LINUX_X86_ARCHIVE")"
|
LINUX_X86_SHA256="$(sha256 "$LINUX_X86_ARCHIVE")"
|
||||||
LINUX_AARCH64_SHA256="$(sha256 "$LINUX_AARCH64_ARCHIVE")"
|
LINUX_AARCH64_SHA256="$(sha256 "$LINUX_AARCH64_ARCHIVE")"
|
||||||
MACOS_ARM64_SHA256="$(sha256 "$MACOS_ARM64_ARCHIVE")"
|
MACOS_ARM64_SHA256="$(sha256 "$MACOS_ARM64_ARCHIVE")"
|
||||||
|
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"${OUTPUT_DIR}/aur/openbitdo" \
|
|
||||||
"${OUTPUT_DIR}/aur/openbitdo-bin" \
|
"${OUTPUT_DIR}/aur/openbitdo-bin" \
|
||||||
"${OUTPUT_DIR}/homebrew/Formula"
|
"${OUTPUT_DIR}/homebrew/Formula"
|
||||||
|
|
||||||
@@ -80,16 +75,12 @@ render() {
|
|||||||
-e "s|@UPSTREAM_TAG@|${TAG}|g" \
|
-e "s|@UPSTREAM_TAG@|${TAG}|g" \
|
||||||
-e "s|@VERSION@|${VERSION}|g" \
|
-e "s|@VERSION@|${VERSION}|g" \
|
||||||
-e "s|@REPOSITORY@|${REPOSITORY}|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_X86_64_SHA256@|${LINUX_X86_SHA256}|g" \
|
||||||
-e "s|@LINUX_AARCH64_SHA256@|${LINUX_AARCH64_SHA256}|g" \
|
-e "s|@LINUX_AARCH64_SHA256@|${LINUX_AARCH64_SHA256}|g" \
|
||||||
-e "s|@MACOS_ARM64_SHA256@|${MACOS_ARM64_SHA256}|g" \
|
-e "s|@MACOS_ARM64_SHA256@|${MACOS_ARM64_SHA256}|g" \
|
||||||
"$template" > "$destination"
|
"$template" > "$destination"
|
||||||
}
|
}
|
||||||
|
|
||||||
render \
|
|
||||||
"${ROOT}/packaging/aur/openbitdo/PKGBUILD.tmpl" \
|
|
||||||
"${OUTPUT_DIR}/aur/openbitdo/PKGBUILD"
|
|
||||||
render \
|
render \
|
||||||
"${ROOT}/packaging/aur/openbitdo-bin/PKGBUILD.tmpl" \
|
"${ROOT}/packaging/aur/openbitdo-bin/PKGBUILD.tmpl" \
|
||||||
"${OUTPUT_DIR}/aur/openbitdo-bin/PKGBUILD"
|
"${OUTPUT_DIR}/aur/openbitdo-bin/PKGBUILD"
|
||||||
@@ -102,7 +93,6 @@ TAG=${TAG}
|
|||||||
VERSION=${VERSION}
|
VERSION=${VERSION}
|
||||||
AUR_PKGVER=${AUR_PKGVER}
|
AUR_PKGVER=${AUR_PKGVER}
|
||||||
REPOSITORY=${REPOSITORY}
|
REPOSITORY=${REPOSITORY}
|
||||||
SOURCE_SHA256=${SOURCE_SHA256}
|
|
||||||
LINUX_X86_64_SHA256=${LINUX_X86_SHA256}
|
LINUX_X86_64_SHA256=${LINUX_X86_SHA256}
|
||||||
LINUX_AARCH64_SHA256=${LINUX_AARCH64_SHA256}
|
LINUX_AARCH64_SHA256=${LINUX_AARCH64_SHA256}
|
||||||
MACOS_ARM64_SHA256=${MACOS_ARM64_SHA256}
|
MACOS_ARM64_SHA256=${MACOS_ARM64_SHA256}
|
||||||
|
|||||||
63
packaging/scripts/test_render_release_metadata.sh
Executable file
63
packaging/scripts/test_render_release_metadata.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | awk '{print $1}'
|
||||||
|
else
|
||||||
|
shasum -a 256 "$1" | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
INPUT_DIR="$TMP/input"
|
||||||
|
OUTPUT_DIR="$TMP/output"
|
||||||
|
mkdir -p "$INPUT_DIR"
|
||||||
|
|
||||||
|
printf 'x86_64 archive payload\n' >"$INPUT_DIR/openbitdo-v0.0.1-rc.4-linux-x86_64.tar.gz"
|
||||||
|
printf 'aarch64 archive payload\n' >"$INPUT_DIR/openbitdo-v0.0.1-rc.4-linux-aarch64.tar.gz"
|
||||||
|
printf 'macos archive payload\n' >"$INPUT_DIR/openbitdo-v0.0.1-rc.4-macos-arm64.tar.gz"
|
||||||
|
|
||||||
|
bash "$ROOT/packaging/scripts/render_release_metadata.sh" \
|
||||||
|
"v0.0.1-rc.4" \
|
||||||
|
"bybrooklyn/openbitdo" \
|
||||||
|
"$INPUT_DIR" \
|
||||||
|
"$OUTPUT_DIR"
|
||||||
|
|
||||||
|
PKGBUILD="$OUTPUT_DIR/aur/openbitdo-bin/PKGBUILD"
|
||||||
|
FORMULA="$OUTPUT_DIR/homebrew/Formula/openbitdo.rb"
|
||||||
|
CHECKSUMS="$OUTPUT_DIR/checksums.env"
|
||||||
|
|
||||||
|
test -f "$PKGBUILD"
|
||||||
|
test -f "$FORMULA"
|
||||||
|
test -f "$CHECKSUMS"
|
||||||
|
|
||||||
|
expected_x86="$(sha256 "$INPUT_DIR/openbitdo-v0.0.1-rc.4-linux-x86_64.tar.gz")"
|
||||||
|
expected_aarch64="$(sha256 "$INPUT_DIR/openbitdo-v0.0.1-rc.4-linux-aarch64.tar.gz")"
|
||||||
|
expected_macos="$(sha256 "$INPUT_DIR/openbitdo-v0.0.1-rc.4-macos-arm64.tar.gz")"
|
||||||
|
|
||||||
|
grep -Fq "pkgver=0.0.1rc4" "$PKGBUILD"
|
||||||
|
grep -Fq "_upstream_tag=v0.0.1-rc.4" "$PKGBUILD"
|
||||||
|
grep -Fq "sha256sums_x86_64=('${expected_x86}')" "$PKGBUILD"
|
||||||
|
grep -Fq "sha256sums_aarch64=('${expected_aarch64}')" "$PKGBUILD"
|
||||||
|
|
||||||
|
grep -Fq 'version "0.0.1-rc.4"' "$FORMULA"
|
||||||
|
grep -Fq "sha256 \"${expected_x86}\"" "$FORMULA"
|
||||||
|
grep -Fq "sha256 \"${expected_aarch64}\"" "$FORMULA"
|
||||||
|
grep -Fq "sha256 \"${expected_macos}\"" "$FORMULA"
|
||||||
|
grep -Fq 'https://github.com/bybrooklyn/openbitdo/releases/download/v0.0.1-rc.4/openbitdo-v0.0.1-rc.4-linux-x86_64.tar.gz' "$FORMULA"
|
||||||
|
|
||||||
|
if grep -nE '@[A-Z0-9_]+@' "$PKGBUILD" "$FORMULA"; then
|
||||||
|
echo "rendered metadata still contains template placeholders" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -Fq "TAG=v0.0.1-rc.4" "$CHECKSUMS"
|
||||||
|
grep -Fq "VERSION=0.0.1-rc.4" "$CHECKSUMS"
|
||||||
|
grep -Fq "AUR_PKGVER=0.0.1rc4" "$CHECKSUMS"
|
||||||
|
grep -Fq "LINUX_X86_64_SHA256=${expected_x86}" "$CHECKSUMS"
|
||||||
|
grep -Fq "LINUX_AARCH64_SHA256=${expected_aarch64}" "$CHECKSUMS"
|
||||||
|
grep -Fq "MACOS_ARM64_SHA256=${expected_macos}" "$CHECKSUMS"
|
||||||
@@ -1,68 +1,37 @@
|
|||||||
# Add Device Support (Hardcoded Path)
|
# Add Device Support
|
||||||
|
|
||||||
This guide keeps device support simple and explicit: everything is added directly in Rust code.
|
This guide describes the clean-room path for adding or promoting a device.
|
||||||
|
|
||||||
## 1) Add/verify PID in hardcoded registry
|
## Update The Runtime Catalog
|
||||||
File:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/crates/bitdo_proto/src/pid_registry_table.rs`
|
|
||||||
|
|
||||||
Add a `PidRegistryRow` with:
|
1. Add or verify the PID row in `sdk/crates/bitdo_proto/src/pid_registry_table.rs`.
|
||||||
- `name`
|
2. Update capability defaults and support-tier policy in `sdk/crates/bitdo_proto/src/registry.rs`.
|
||||||
- `pid`
|
3. Add or verify command rows in `sdk/crates/bitdo_proto/src/command_registry_table.rs`.
|
||||||
- `support_level`
|
4. Update candidate-readonly gating in `sdk/crates/bitdo_proto/src/session.rs` when the new PID needs safe-read diagnostics.
|
||||||
- `support_tier`
|
|
||||||
- `protocol_family`
|
|
||||||
|
|
||||||
## 2) Update capability policy
|
## Update The Sanitized Evidence
|
||||||
File:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/crates/bitdo_proto/src/registry.rs`
|
|
||||||
|
|
||||||
Update `default_capability_for(...)` and support-tier PID lists so capability flags match evidence.
|
Keep the spec and evidence artifacts aligned:
|
||||||
|
|
||||||
## 3) Add/verify command declarations
|
- `spec/device_name_catalog.md`
|
||||||
File:
|
- `spec/protocol_spec.md`
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/crates/bitdo_proto/src/command_registry_table.rs`
|
- `process/device_name_sources.md`
|
||||||
|
- dossier and matrix artifacts where applicable
|
||||||
|
|
||||||
Add/verify command rows:
|
## Update Tests
|
||||||
- `id`
|
|
||||||
- `safety_class`
|
|
||||||
- `confidence`
|
|
||||||
- `experimental_default`
|
|
||||||
- `report_id`
|
|
||||||
- `request`
|
|
||||||
- `expected_response`
|
|
||||||
- `applies_to`
|
|
||||||
- `operation_group`
|
|
||||||
|
|
||||||
## 4) Confirm runtime policy
|
At minimum, touch the tests that prove:
|
||||||
Runtime policy is derived in code (not scripts):
|
|
||||||
- `confirmed` -> enabled by default
|
|
||||||
- inferred `SafeRead` -> experimental-gated
|
|
||||||
- inferred `SafeWrite`/unsafe -> blocked until confirmed
|
|
||||||
|
|
||||||
File:
|
- support-tier gating is correct
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/crates/bitdo_proto/src/registry.rs`
|
- command/runtime policy is correct
|
||||||
|
- diagnostics or mapping behavior is correct for the new device family
|
||||||
|
|
||||||
## 5) Update candidate gating allowlists
|
## Validation
|
||||||
File:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/crates/bitdo_proto/src/session.rs`
|
|
||||||
|
|
||||||
Update `is_command_allowed_for_candidate_pid(...)` so detect/diag behavior for the new PID is explicit.
|
From `cleanroom/sdk`:
|
||||||
|
|
||||||
## 6) Keep spec artifacts in sync
|
```bash
|
||||||
Files:
|
./scripts/cleanroom_guard.sh
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/spec/pid_matrix.csv`
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/spec/command_matrix.csv`
|
cargo test --workspace --all-targets
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/spec/evidence_index.csv`
|
```
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/spec/dossiers/...`
|
|
||||||
|
|
||||||
## 7) Add tests
|
|
||||||
- Extend candidate gating tests:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/tests/candidate_readonly_gating.rs`
|
|
||||||
- Extend runtime policy tests:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/sdk/tests/runtime_policy.rs`
|
|
||||||
|
|
||||||
## 8) Validation
|
|
||||||
From `/Users/brooklyn/data/8bitdo/cleanroom/sdk`:
|
|
||||||
- `cargo test --workspace --all-targets`
|
|
||||||
- `./scripts/cleanroom_guard.sh`
|
|
||||||
|
|||||||
59
process/aur_publish_troubleshooting.md
Normal file
59
process/aur_publish_troubleshooting.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# AUR Publish Troubleshooting
|
||||||
|
|
||||||
|
Use this runbook when the AUR release path fails, especially on SSH authentication.
|
||||||
|
|
||||||
|
## Typical Failure Signatures
|
||||||
|
|
||||||
|
- `Permission denied (publickey)`
|
||||||
|
- `Host key verification failed`
|
||||||
|
- missing `AUR_USERNAME`
|
||||||
|
- missing `AUR_SSH_PRIVATE_KEY`
|
||||||
|
|
||||||
|
## Local SSH Sanity Check
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
- the key parses successfully
|
||||||
|
- no CI-incompatible passphrase prompt
|
||||||
|
|
||||||
|
## Known Hosts Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||||
|
chmod 600 ~/.ssh/known_hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote Auth Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /tmp/aur-debug/aur_key \
|
||||||
|
-o IdentitiesOnly=yes \
|
||||||
|
-o StrictHostKeyChecking=yes \
|
||||||
|
"${AUR_USERNAME}@aur.archlinux.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
An immediate disconnect after auth is still acceptable. It proves the key is valid.
|
||||||
|
|
||||||
|
## Repo Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git ls-remote "ssh://${AUR_USERNAME}@aur.archlinux.org/openbitdo-bin.git"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this fails, the AUR account or key pairing is still wrong.
|
||||||
|
|
||||||
|
## After The Fix
|
||||||
|
|
||||||
|
- rerun the release workflow
|
||||||
|
- confirm `publish-aur` succeeds
|
||||||
|
- confirm `openbitdo-bin` points at the new release tag
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
# Branch and Merge Policy
|
# Branch Policy
|
||||||
|
|
||||||
Because this workspace currently has no active Git repository metadata, this policy is documented for use when repository control is re-enabled.
|
## Defaults
|
||||||
|
|
||||||
## Branches
|
- default branch: `main`
|
||||||
- `codex/dirtyroom-spec`: sanitize findings into `cleanroom/spec` and `cleanroom/process`
|
- automation/worktree branches: `codex/*`
|
||||||
- `codex/cleanroom-sdk`: implement SDK and CLI from sanitized artifacts only
|
- release tags: `v*`
|
||||||
|
|
||||||
## Merge Strategy
|
## Merge Expectations
|
||||||
- Cherry-pick sanitized spec commits from dirtyroom branch into cleanroom branch.
|
|
||||||
- Never merge dirty-room evidence paths into cleanroom implementation branch.
|
- clean-room implementation stays in `cleanroom/`
|
||||||
|
- dirty-room or decompiler material never lands in runtime, tests, docs, or workflows inside this tree
|
||||||
|
- release tags are created from commits that are already on `main`
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- Guard script passes.
|
|
||||||
- No forbidden path references in code/tests.
|
- clean-room guard passes
|
||||||
- Requirement IDs are traceable from implementation and tests.
|
- no forbidden path references were introduced
|
||||||
|
- docs and release metadata are consistent with the current RC
|
||||||
|
- required CI checks stay green
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
# Clean-Room Rules
|
# Clean-Room Rules
|
||||||
|
|
||||||
## Allowed Inputs During Clean Implementation
|
## Allowed Inputs
|
||||||
- `cleanroom/spec/**`
|
|
||||||
- `cleanroom/process/cleanroom_rules.md`
|
|
||||||
- `cleanroom/harness/golden/**`
|
|
||||||
|
|
||||||
## Forbidden Inputs During Clean Implementation
|
- `cleanroom/spec/**`
|
||||||
- `decompiled/**`
|
- `cleanroom/process/**`
|
||||||
- `decompiled_*/*`
|
- approved harness fixtures and generated release artifacts
|
||||||
- `bundle_extract/**`
|
|
||||||
- `extracted/**`
|
## Forbidden Inputs
|
||||||
- `extracted_net/**`
|
|
||||||
- `session-ses_35e4.md`
|
- decompiled vendor code
|
||||||
|
- copied proprietary snippets
|
||||||
|
- direct references to dirty-room paths from clean implementation or user-facing docs
|
||||||
|
|
||||||
## Enforcement
|
## Enforcement
|
||||||
- `cleanroom/sdk/scripts/cleanroom_guard.sh` checks for forbidden path and token references.
|
|
||||||
- CI runs the guard before test jobs.
|
- `cleanroom/sdk/scripts/cleanroom_guard.sh` scans for forbidden references
|
||||||
|
- CI runs the guard before packaging and test jobs
|
||||||
|
|
||||||
## Commit Hygiene
|
## Commit Hygiene
|
||||||
- No copied decompiled code snippets.
|
|
||||||
- No direct references to dirty-room files in SDK implementation/tests.
|
- no copied decompiled code
|
||||||
- Any new protocol fact must be added to sanitized spec artifacts first.
|
- no raw vendor-source excerpts
|
||||||
|
- new protocol facts must arrive through sanitized spec or evidence updates first
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
# OpenBitdo Commenting Standard
|
# Commenting Standard
|
||||||
|
|
||||||
This project prefers concise, high-context comments.
|
OpenBitdo prefers sparse, high-value comments.
|
||||||
|
|
||||||
## Required Comment Zones
|
## Add Comments When
|
||||||
- Command gating order and rationale (`bitdo_proto::session`).
|
|
||||||
- Support-tier decisions and promotion boundaries (`candidate-readonly` vs `full`).
|
|
||||||
- Unsafe/firmware blocking rules and brick-risk protections.
|
|
||||||
- Retry/fallback behavior where multiple command paths exist.
|
|
||||||
- State-machine transitions in TUI/app-core flows when transitions are non-obvious.
|
|
||||||
|
|
||||||
## Avoid
|
- safety or brick-risk behavior is non-obvious
|
||||||
- Trivial comments that restate code syntax.
|
- support-tier gating would be easy to misread
|
||||||
- Comment blocks that drift from behavior and are not maintained.
|
- retries, fallbacks, or validator behavior need rationale
|
||||||
|
- a state transition matters more than the literal code line
|
||||||
|
|
||||||
## Rule of Thumb
|
## Avoid Comments When
|
||||||
If someone adding a new device could misread a policy or safety boundary, comment it.
|
|
||||||
|
- the code already says the same thing clearly
|
||||||
|
- the comment would become stale as soon as names or branches change
|
||||||
|
- the comment explains syntax instead of intent
|
||||||
|
|
||||||
|
## Rule Of Thumb
|
||||||
|
|
||||||
|
If a future contributor could accidentally weaken a safety boundary, the surrounding code deserves a short comment.
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
# Community Evidence Intake
|
# Community Evidence Intake
|
||||||
|
|
||||||
## Purpose
|
This process collects safe, sanitized device evidence from users and testers.
|
||||||
Collect hardware and protocol evidence from the community in a clean-room-safe format.
|
|
||||||
|
|
||||||
## Submission Requirements
|
## Required Submission Data
|
||||||
Every report must include:
|
|
||||||
- Device name
|
- device name
|
||||||
- VID/PID (`0xVVVV:0xPPPP`)
|
- VID/PID in `0xVVVV:0xPPPP` form
|
||||||
- Firmware version shown by official software/device
|
- firmware version
|
||||||
- Operation attempted
|
- operation attempted
|
||||||
- Sanitized request/response shape description
|
- sanitized request or response description
|
||||||
- Reproducibility notes (steps, OS, transport mode)
|
- OS, transport mode, and reproducibility notes
|
||||||
|
|
||||||
## Prohibited Content
|
## Prohibited Content
|
||||||
- Raw copied decompiled code.
|
|
||||||
- Vendor source snippets.
|
|
||||||
- Binary dumps with proprietary content not required for protocol structure.
|
|
||||||
|
|
||||||
## Acceptance Levels
|
- raw decompiled code
|
||||||
- `intake`: report received, unverified.
|
- copied vendor source snippets
|
||||||
- `triaged`: mapped to a PID/operation group and requirement IDs.
|
- proprietary dumps that are not sanitized into structure-level notes
|
||||||
- `accepted`: converted into sanitized dossier/spec updates.
|
|
||||||
|
|
||||||
## Maintainer Processing
|
## Maintainer Flow
|
||||||
1. Validate report format.
|
|
||||||
2. Cross-reference PID with `spec/pid_matrix.csv`.
|
1. validate the report format
|
||||||
3. Create/update `spec/dossiers/<pid_hex>/*.toml`.
|
2. map it to a known PID or create a new sanitized record
|
||||||
4. Update `spec/evidence_index.csv` and command/pid matrices.
|
3. update the relevant spec or dossier artifacts
|
||||||
5. Keep device as `candidate-readonly` until full 3-signal promotion gate is met.
|
4. keep the device read-only until runtime and hardware confirmation justify promotion
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
# Device Name Sources
|
# Device Name Sources
|
||||||
|
|
||||||
This index lists sanitized naming sources used to build `/Users/brooklyn/data/8bitdo/cleanroom/spec/device_name_catalog.md`.
|
This index explains where canonical device names came from and how confident the project is in them.
|
||||||
|
|
||||||
## Primary dirty-room references
|
## Source Classes
|
||||||
- `DR-1`: Decompiled PID constants table (`VIDPID`) in `/Users/brooklyn/data/8bitdo/decompiled_dll/8BitDo_Ultimate_Software_V2.decompiled.cs` around line 194289.
|
|
||||||
- `DR-2`: Decompiled device name resolver (`getName()`) in `/Users/brooklyn/data/8bitdo/decompiled_dll/8BitDo_Ultimate_Software_V2.decompiled.cs` around line 12249.
|
|
||||||
- `DR-3`: Decompiled language map (`LanguageTools`) in `/Users/brooklyn/data/8bitdo/decompiled_dll/8BitDo_Ultimate_Software_V2.decompiled.cs` around line 206277.
|
|
||||||
|
|
||||||
## Official 8BitDo web cross-check references
|
- sanitized dirty-room references from internal naming tables
|
||||||
- `WEB-1`: [8BitDo product catalog](https://www.8bitdo.com/#Products)
|
- official 8BitDo product pages
|
||||||
- `WEB-2`: [Ultimate 2 Wireless Controller](https://www.8bitdo.com/ultimate-2-wireless-controller/)
|
- project-maintained fallback names when neither source is strong enough for a public label
|
||||||
- `WEB-3`: [Ultimate 2 Bluetooth Controller](https://www.8bitdo.com/ultimate-2-bluetooth-controller/)
|
|
||||||
- `WEB-4`: [Ultimate 2C Wireless Controller](https://www.8bitdo.com/ultimate-2c-wireless-controller/)
|
|
||||||
- `WEB-5`: [Ultimate 2C Bluetooth Controller](https://www.8bitdo.com/ultimate-2c-bluetooth-controller/)
|
|
||||||
- `WEB-6`: [Pro 2 Bluetooth Controller](https://www.8bitdo.com/pro2/)
|
|
||||||
- `WEB-7`: [Retro 108 Mechanical Keyboard](https://www.8bitdo.com/retro-108-mechanical-keyboard/)
|
|
||||||
- `WEB-8`: [Retro 87 Mechanical Keyboard - Xbox Edition](https://www.8bitdo.com/retro-87-mechanical-keyboard-xbox/)
|
|
||||||
- `WEB-9`: [Retro R8 Mouse - Xbox Edition](https://www.8bitdo.com/retro-r8-mouse-xbox/)
|
|
||||||
- `WEB-10`: [Arcade Controller](https://www.8bitdo.com/arcade-controller/)
|
|
||||||
- `WEB-11`: [64 Bluetooth Controller](https://www.8bitdo.com/64-controller/)
|
|
||||||
- `WEB-12`: [Retro Mechanical Keyboard N Edition](https://www.8bitdo.com/retro-mechanical-keyboard/)
|
|
||||||
|
|
||||||
## Confidence policy
|
## Confidence Policy
|
||||||
- `high`: direct match in `DR-2/DR-3` and/or product page exact-name match.
|
|
||||||
- `medium`: strong inferred match from product family naming with at least one source anchor.
|
|
||||||
- `low`: internal fallback name because no confident public marketing name was found.
|
|
||||||
|
|
||||||
## Low-confidence naming convention
|
- `high`: exact or near-exact public confirmation
|
||||||
- Canonical wording for low-confidence rows is:
|
- `medium`: strong inferred match from family and naming context
|
||||||
- `Unconfirmed Internal Device (PID_*)`
|
- `low`: fallback wording kept intentionally conservative
|
||||||
- `Unconfirmed Variant Name (PID_*)`
|
|
||||||
- `Unconfirmed Interface Name (PID_*)`
|
## Low-Confidence Naming Convention
|
||||||
|
|
||||||
|
Use conservative labels such as:
|
||||||
|
|
||||||
|
- `Unconfirmed Internal Device (...)`
|
||||||
|
- `Unconfirmed Variant Name (...)`
|
||||||
|
- `Unconfirmed Interface Name (...)`
|
||||||
|
|
||||||
|
## Current Official Cross-Checks
|
||||||
|
|
||||||
|
- [8BitDo product catalog](https://www.8bitdo.com/#Products)
|
||||||
|
- [Ultimate 2 Wireless Controller](https://www.8bitdo.com/ultimate-2-wireless-controller/)
|
||||||
|
- [Ultimate 2 Bluetooth Controller](https://www.8bitdo.com/ultimate-2-bluetooth-controller/)
|
||||||
|
- [Ultimate 2C Wireless Controller](https://www.8bitdo.com/ultimate-2c-wireless-controller/)
|
||||||
|
- [Ultimate 2C Bluetooth Controller](https://www.8bitdo.com/ultimate-2c-bluetooth-controller/)
|
||||||
|
- [Pro 2 Bluetooth Controller](https://www.8bitdo.com/pro2/)
|
||||||
|
- [Retro 108 Mechanical Keyboard](https://www.8bitdo.com/retro-108-mechanical-keyboard/)
|
||||||
|
- [Retro 87 Mechanical Keyboard - Xbox Edition](https://www.8bitdo.com/retro-87-mechanical-keyboard-xbox/)
|
||||||
|
- [Retro R8 Mouse - Xbox Edition](https://www.8bitdo.com/retro-r8-mouse-xbox/)
|
||||||
|
- [Arcade Controller](https://www.8bitdo.com/arcade-controller/)
|
||||||
|
- [64 Bluetooth Controller](https://www.8bitdo.com/64-controller/)
|
||||||
|
- [Retro Mechanical Keyboard N Edition](https://www.8bitdo.com/retro-mechanical-keyboard/)
|
||||||
|
|||||||
@@ -1,60 +1,40 @@
|
|||||||
# Dirty-Room Collection Playbook (Decompiler-First Expansion)
|
# Dirty-Room Collection Playbook
|
||||||
|
|
||||||
|
This playbook describes how dirty-room evidence is gathered and sanitized before the clean-room implementation consumes it.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Create sanitized, requirement-linked evidence that expands device detect/diagnostics support without contaminating clean-room implementation.
|
|
||||||
|
|
||||||
## Scope of This Wave
|
Produce sanitized, requirement-linked evidence that can expand detect, diagnostics, mapping, or firmware understanding without copying vendor material into the runtime.
|
||||||
- Evidence source: decompiler/static-only.
|
|
||||||
- Target: Wave 2 +12 popularity cohort (plus previously tracked candidate-readonly set).
|
|
||||||
- Promotion policy: no new `full` promotions in this wave.
|
|
||||||
- Output artifacts: `spec/dossiers/**`, `spec/evidence_index.csv`, updated `spec/*.csv`, updated `requirements.yaml`.
|
|
||||||
|
|
||||||
## Allowed Dirty-Room Inputs
|
## Allowed Inputs
|
||||||
- `/Users/brooklyn/data/8bitdo/decompiled_dll/8BitDo_Ultimate_Software_V2.decompiled.cs`
|
|
||||||
- `/Users/brooklyn/data/8bitdo/decompiled/*.cs`
|
- approved decompiler outputs and existing dirty-room transcripts
|
||||||
- `/Users/brooklyn/data/8bitdo/decompiled_autoupdate/*.cs`
|
- official public web pages used for naming or marketing confirmation
|
||||||
- Existing dirty-room transcript files under `/Users/brooklyn/data/8bitdo/`
|
|
||||||
|
## Required Sanitization
|
||||||
|
|
||||||
|
Record only structure-level findings:
|
||||||
|
|
||||||
## Required Sanitization Rules
|
|
||||||
- Do not copy raw vendor/decompiled code snippets into clean artifacts.
|
|
||||||
- Record only sanitized structure-level findings:
|
|
||||||
- command intent
|
- command intent
|
||||||
- request/response byte-shape
|
- request and response shape
|
||||||
- validator expectations
|
- validator expectations
|
||||||
- gating/policy notes
|
- retry or failure behavior
|
||||||
- Use requirement IDs only (`REQ-DR-*`, `REQ-PROM-*`, `REQ-COMM-*`, `REQ-GH-*`).
|
- promotion or safety notes
|
||||||
|
|
||||||
|
Do not copy vendor code or raw proprietary snippets.
|
||||||
|
|
||||||
## Dossier Workflow
|
## Dossier Workflow
|
||||||
1. Pick PID and operation group.
|
|
||||||
2. Collect static evidence anchors (class/function names and behavior summaries).
|
|
||||||
3. Derive sanitized command mapping and validation expectations.
|
|
||||||
4. Write TOML dossier in `spec/dossiers/<pid_hex>/<operation_group>.toml`.
|
|
||||||
5. Link dossier ID into `spec/command_matrix.csv` (`dossier_id` column).
|
|
||||||
6. Update `spec/evidence_index.csv`.
|
|
||||||
7. Ensure each Wave 2 PID has all three required dossier files:
|
|
||||||
- `core_diag.toml`
|
|
||||||
- `mode_or_profile_read.toml`
|
|
||||||
- `firmware_preflight.toml`
|
|
||||||
|
|
||||||
## Authoring Approach (No Helper Scripts)
|
1. choose a PID and operation group
|
||||||
- Dossiers and matrix updates are maintained directly in repository source files.
|
2. collect anchors and summarize them
|
||||||
- `spec/evidence_index.csv` is updated manually with deterministic ordering.
|
3. create or update the sanitized dossier
|
||||||
- Validation is performed through normal repository review plus workspace tests.
|
4. link the dossier from the relevant matrix rows
|
||||||
|
5. update evidence indexes and notes
|
||||||
|
|
||||||
## Confidence Rules
|
## Promotion Rule
|
||||||
- `confirmed`: requires static + runtime + hardware confirmation (not achievable in this wave).
|
|
||||||
- `inferred`: static-only or partial confidence.
|
|
||||||
- For this wave, new entries should remain `inferred` unless already confirmed historically.
|
|
||||||
|
|
||||||
## Promotion Gate
|
Moving from `read-only candidate` to `supported` requires:
|
||||||
A device can move from `candidate-readonly` to `full` only when all three are true:
|
|
||||||
1. static evidence complete
|
|
||||||
2. runtime trace evidence complete
|
|
||||||
3. hardware read/write/readback complete
|
|
||||||
|
|
||||||
## Review Checklist
|
1. static evidence
|
||||||
- Dossier contains required fields from schema.
|
2. runtime traces
|
||||||
- Requirement linkage is explicit.
|
3. hardware confirmation
|
||||||
- No raw decompiled text/snippets are present.
|
|
||||||
- `support_tier` remains `candidate-readonly` for new no-hardware devices.
|
|
||||||
- Runtime and hardware placeholders are populated with concrete promotion evidence tasks.
|
|
||||||
|
|||||||
@@ -1,56 +1,30 @@
|
|||||||
# Dirty-Room Dossier Schema
|
# Dirty-Room Dossier Schema
|
||||||
|
|
||||||
Each dossier file is TOML and must include these fields.
|
Each dossier is a TOML file that captures sanitized protocol evidence for one PID and one operation group.
|
||||||
|
|
||||||
## Required Fields
|
## Required Fields
|
||||||
- `dossier_id`: stable identifier, e.g. `DOS-5200-CORE`.
|
|
||||||
- `pid_hex`: target PID in hex (`0xNNNN`).
|
- `dossier_id`
|
||||||
- `operation_group`: logical grouping (`CoreDiag`, `ModeProfileRead`, `FirmwarePreflight`, etc).
|
- `pid_hex`
|
||||||
- `command_id`: array of command IDs scoped by this dossier.
|
- `operation_group`
|
||||||
- `request_shape`: sanitized request structure summary.
|
- `command_id`
|
||||||
- `response_shape`: sanitized response structure summary.
|
- `request_shape`
|
||||||
- `validator_rules`: array of response validation constraints.
|
- `response_shape`
|
||||||
- `retry_behavior`: retry and timeout behavior summary.
|
- `validator_rules`
|
||||||
- `failure_signatures`: array of known failure signatures.
|
- `retry_behavior`
|
||||||
- `evidence_source`: `static` for this wave.
|
- `failure_signatures`
|
||||||
- `confidence`: `inferred` or `confirmed`.
|
- `evidence_source`
|
||||||
- `requirement_ids`: array of linked requirement IDs.
|
- `confidence`
|
||||||
- `state_machine`: table with `pre_state`, `action`, `post_state`, and `invalid_transitions`.
|
- `requirement_ids`
|
||||||
- `runtime_placeholder`: table with `required` and `evidence_needed`.
|
- `state_machine`
|
||||||
- `hardware_placeholder`: table with `required` and `evidence_needed`.
|
- `runtime_placeholder`
|
||||||
|
- `hardware_placeholder`
|
||||||
|
|
||||||
## Optional Fields
|
## Optional Fields
|
||||||
- `class_family`: static class-family grouping hints.
|
|
||||||
- `notes`: additional sanitized context.
|
|
||||||
|
|
||||||
## Example
|
- `class_family`
|
||||||
```toml
|
- `notes`
|
||||||
dossier_id = "DOS-5200-CORE"
|
|
||||||
pid_hex = "0x5200"
|
|
||||||
operation_group = "CoreDiag"
|
|
||||||
command_id = ["GetPid", "GetReportRevision", "GetControllerVersion", "Version", "Idle"]
|
|
||||||
request_shape = "64-byte HID report, command byte in report[1], PID-specific gating outside payload"
|
|
||||||
response_shape = "short status header plus optional payload bytes"
|
|
||||||
validator_rules = ["byte0 == 0x02", "response length >= 4"]
|
|
||||||
retry_behavior = "retry up to configured max attempts on timeout/malformed response"
|
|
||||||
failure_signatures = ["timeout", "malformed response", "unsupported command for pid"]
|
|
||||||
evidence_source = "static"
|
|
||||||
confidence = "inferred"
|
|
||||||
requirement_ids = ["REQ-DR-001", "REQ-PROM-001", "REQ-PID-002"]
|
|
||||||
class_family = "JP/Handshake path"
|
|
||||||
notes = "candidate-readonly in this wave"
|
|
||||||
|
|
||||||
[state_machine]
|
## Authoring Rule
|
||||||
pre_state = "DeviceConnected"
|
|
||||||
action = "Run core diagnostics reads"
|
|
||||||
post_state = "DeviceIdentified"
|
|
||||||
invalid_transitions = ["NoDevice", "TransportClosed", "BootloaderOnly"]
|
|
||||||
|
|
||||||
[runtime_placeholder]
|
Prefer short, structure-level descriptions over long prose. The dossier should be good enough to guide clean implementation and testing without embedding dirty-room source text.
|
||||||
required = true
|
|
||||||
evidence_needed = ["runtime request/response captures", "error signature examples"]
|
|
||||||
|
|
||||||
[hardware_placeholder]
|
|
||||||
required = true
|
|
||||||
evidence_needed = ["physical read validation", "repeatability checks"]
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,50 +1,18 @@
|
|||||||
# Dirty-Room Evidence Backlog
|
# Dirty-Room Evidence Backlog
|
||||||
|
|
||||||
## Purpose
|
This backlog tracks the next protocol-evidence areas that would most improve runtime confidence.
|
||||||
|
|
||||||
Track future dirty-room evidence work for protocol expansion in a structured way, so new functionality can be translated into sanitized clean-room specs without contaminating implementation code.
|
## Priority Areas
|
||||||
|
|
||||||
## Clean-Room Boundaries
|
1. read-only candidate expansion for additional controller families
|
||||||
|
2. deeper JP108 mapping confirmation
|
||||||
|
3. deeper Ultimate 2 profile and slot behavior confirmation
|
||||||
|
4. firmware trace and recovery evidence across supported devices
|
||||||
|
5. runtime and hardware evidence needed to promote candidate devices
|
||||||
|
|
||||||
- Dirty-room analysis may use approved evidence sources.
|
## Expected Outputs
|
||||||
- Clean implementation must consume only sanitized artifacts in `spec/` and approved harness data.
|
|
||||||
- No raw dirty-room snippets, copied code, or direct decompiled fragments may be carried into clean implementation files.
|
|
||||||
|
|
||||||
## Prioritized Backlog
|
- updated spec and requirement references
|
||||||
|
- refreshed command and PID matrices
|
||||||
1. Wave-2 candidate-readonly expansion (decompiler-first):
|
- sanitized dossier updates
|
||||||
- Popularity +12 PIDs:
|
- optional harness fixtures where they improve regression coverage
|
||||||
- `0x3100`, `0x3105`, `0x2100`, `0x2101`, `0x901a`, `0x6006`
|
|
||||||
- `0x5203`, `0x5204`, `0x301a`, `0x9028`, `0x3026`, `0x3027`
|
|
||||||
- Deliverable posture: detect/diag-only (`candidate-readonly`), no firmware transfer/write promotion.
|
|
||||||
2. Wave-1 candidate-readonly follow-through:
|
|
||||||
- Primary 14 PIDs:
|
|
||||||
- `0x6002`, `0x6003`, `0x3010`, `0x3011`, `0x3012`, `0x3013`
|
|
||||||
- `0x5200`, `0x5201`, `0x203a`, `0x2049`, `0x2028`, `0x202e`
|
|
||||||
- `0x3004`, `0x3019`
|
|
||||||
- Stretch PIDs:
|
|
||||||
- `0x3021`, `0x2039`, `0x2056`, `0x5205`, `0x5206`
|
|
||||||
- Deliverable posture: stay candidate-readonly until runtime and hardware evidence is accepted.
|
|
||||||
3. JP108 deeper mapping coverage:
|
|
||||||
- Expand dedicated key mapping confirmation beyond the current A/B/K1-K8 baseline.
|
|
||||||
- Confirm feature and voice command behavior with stronger request/response confidence.
|
|
||||||
4. Ultimate2 advanced paths:
|
|
||||||
- Expand confidence for advanced slot/config interactions and additional profile behaviors.
|
|
||||||
- Confirm edge cases for mode transitions and per-slot persistence.
|
|
||||||
5. Firmware trace confidence:
|
|
||||||
- Increase confidence for bootloader enter/chunk/commit/exit behavior across supported target variants.
|
|
||||||
- Capture and sanitize additional failure and recovery traces.
|
|
||||||
|
|
||||||
## Required Sanitized Outputs
|
|
||||||
|
|
||||||
- Update `spec/protocol_spec.md` for any newly confirmed operation groups or behavior rules.
|
|
||||||
- Update `spec/requirements.yaml` with new stable requirement IDs.
|
|
||||||
- Update `spec/command_matrix.csv` and `spec/pid_matrix.csv` as evidence confidence changes.
|
|
||||||
- Add or refresh sanitized harness fixtures under `harness/golden/` for replay and regression tests.
|
|
||||||
|
|
||||||
## Review Checklist Before Clean Implementation
|
|
||||||
|
|
||||||
- Sanitized evidence is traceable to requirement IDs.
|
|
||||||
- Command confidence levels are explicit (`confirmed` vs `inferred`).
|
|
||||||
- PID capability changes are reflected in matrices.
|
|
||||||
- No raw-source text is present in clean implementation artifacts.
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
# Wave 1 Baseline Snapshot
|
# Wave 1 Baseline Snapshot
|
||||||
|
|
||||||
Generated: 2026-02-28
|
Generated on 2026-02-28.
|
||||||
|
|
||||||
## Hardware Access Baseline
|
## Baseline
|
||||||
- Available hardware lines: JP108 + Ultimate2.
|
|
||||||
- Exact attached PID variants: pending local identify run on connected hardware.
|
|
||||||
- Temporary lab fixture defaults remain:
|
|
||||||
- JP108: `0x5209`
|
|
||||||
- Ultimate2: `0x6012`
|
|
||||||
|
|
||||||
## Required Next Verification Step
|
- owned fixture families: JP108 and Ultimate 2
|
||||||
Run identify flow with connected hardware and confirm fixture PIDs:
|
- existing supported paths were limited to hardware-confirmed devices
|
||||||
1. `cargo test --workspace --test hardware_smoke -- --ignored --exact hardware_smoke_detect_devices`
|
- newly expanded devices were intentionally kept read-only
|
||||||
2. `./scripts/run_hardware_smoke.sh` with `BITDO_REQUIRED_SUITE=108jp`
|
|
||||||
3. `./scripts/run_hardware_smoke.sh` with `BITDO_REQUIRED_SUITE=ultimate2`
|
|
||||||
|
|
||||||
If detected variants differ, update `harness/lab/device_lab.yaml` fixture PIDs immediately.
|
## Expected Follow-Up
|
||||||
|
|
||||||
## Support Baseline
|
- capture runtime traces
|
||||||
- Existing `full` paths: JP108/Ultimate2 and previously confirmed families.
|
- confirm real hardware behavior
|
||||||
- New expansion wave devices remain `candidate-readonly` (detect/diag only).
|
- promote devices only after static, runtime, and hardware evidence align
|
||||||
- No new firmware/write enablement for no-hardware targets.
|
|
||||||
|
|||||||
@@ -1,23 +1,9 @@
|
|||||||
# Wave 1 Results (Template)
|
# Wave 1 Results
|
||||||
|
|
||||||
Generated: 2026-02-28
|
Generated on 2026-02-28.
|
||||||
|
|
||||||
## Summary
|
## Outcome
|
||||||
- Primary target PIDs processed: 14
|
|
||||||
- Stretch target PIDs processed: TBD
|
|
||||||
- New `full` promotions: 0 (expected in decompiler-only wave)
|
|
||||||
|
|
||||||
## Deliverables
|
- Wave 1 produced sanitized evidence and candidate-readonly expansion work.
|
||||||
- Dossiers created: `spec/dossiers/**`
|
- No new device families were promoted directly to full support.
|
||||||
- Evidence index updated: `spec/evidence_index.csv`
|
- Follow-up work remained runtime traces and hardware confirmation.
|
||||||
- Matrices updated: `spec/pid_matrix.csv`, `spec/command_matrix.csv`
|
|
||||||
- Requirements updated: `spec/requirements.yaml`
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
- `cargo test --workspace --all-targets`: pending
|
|
||||||
- `./scripts/cleanroom_guard.sh`: pending
|
|
||||||
- Detect/diag targeted tests: pending
|
|
||||||
|
|
||||||
## Follow-Up
|
|
||||||
- Collect runtime traces for candidate-readonly devices.
|
|
||||||
- Run hardware confirmation on each candidate before promotion to `full`.
|
|
||||||
|
|||||||
@@ -1,32 +1,17 @@
|
|||||||
# Wave 2 Baseline (Frozen)
|
# Wave 2 Baseline
|
||||||
|
|
||||||
## Snapshot Date
|
Snapshot date: 2026-03-01.
|
||||||
- 2026-03-01
|
|
||||||
|
|
||||||
## Pre-Wave Counts (Frozen)
|
## Baseline Assumptions
|
||||||
- `spec/pid_matrix.csv` rows: 59
|
|
||||||
- Support tier counts (pre-wave):
|
|
||||||
- `full`: 14
|
|
||||||
- `candidate-readonly`: 15
|
|
||||||
- `detect-only`: 30
|
|
||||||
- `spec/command_matrix.csv` rows (pre-wave): 37
|
|
||||||
|
|
||||||
## Hardware Reality (Current)
|
- hardware ownership still covered only the JP108 and Ultimate 2 lines
|
||||||
- Available fixtures: JP108 line and Ultimate2 line only.
|
- newly researched PIDs had to remain read-only until confirmation improved
|
||||||
- Non-owned devices must remain `candidate-readonly` until strict promotion signals are complete.
|
- release gates stayed anchored on `guard`, `aur-validate`, `tui-smoke-test`, `build-macos-arm64`, and `test`
|
||||||
|
|
||||||
## Required Checks Baseline
|
|
||||||
Branch protection for `main` must require:
|
|
||||||
- `guard`
|
|
||||||
- `aur-validate`
|
|
||||||
- `tui-smoke-test`
|
|
||||||
- `build-macos-arm64`
|
|
||||||
- `test`
|
|
||||||
- `hardware-108jp`
|
|
||||||
- `hardware-ultimate2`
|
|
||||||
|
|
||||||
## Promotion Policy
|
## Promotion Policy
|
||||||
Promotion from `candidate-readonly` to `full` requires all 3 signals:
|
|
||||||
1. static dossier evidence,
|
Promotion from `read-only candidate` to `supported` requires:
|
||||||
2. runtime sanitized traces,
|
|
||||||
3. hardware read/write/readback confirmation.
|
1. static evidence
|
||||||
|
2. runtime evidence
|
||||||
|
3. hardware evidence
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
# Wave 2 PID Scorecard
|
# Wave 2 PID Scorecard
|
||||||
|
|
||||||
Legend:
|
Legend:
|
||||||
- Static: dossier + matrix linkage complete.
|
|
||||||
- Runtime: sanitized runtime traces accepted.
|
- `Static`: dossier and matrix linkage complete
|
||||||
- Hardware: read/write/readback confirmed on owned fixture.
|
- `Runtime`: sanitized runtime traces accepted
|
||||||
- Gate: promotion blocker status.
|
- `Hardware`: real-device confirmation accepted
|
||||||
|
- `Gate`: reason promotion remains blocked
|
||||||
|
|
||||||
| PID | Device | Tier | Static | Runtime | Hardware | Gate |
|
| PID | Device | Tier | Static | Runtime | Hardware | Gate |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| `0x3100` | PID_USB_Ultimate | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x3100` | `PID_USB_Ultimate` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x3105` | PID_USB_Ultimate2 | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x3105` | `PID_USB_Ultimate2` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x2100` | PID_Xcloud | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x2100` | `PID_Xcloud` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x2101` | PID_Xcloud2 | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x2101` | `PID_Xcloud2` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x901a` | PID_ArcadeStick | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x901a` | `PID_ArcadeStick` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x6006` | PID_Pro2_CY | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x6006` | `PID_Pro2_CY` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x5203` | PID_NUMPAD | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x5203` | `PID_NUMPAD` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x5204` | PID_NUMPADRR | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x5204` | `PID_NUMPADRR` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x301a` | PID_UltimateBT2C | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x301a` | `PID_UltimateBT2C` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x9028` | PID_N64RR | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x9028` | `PID_N64RR` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x3026` | PID_XBOXUK | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x3026` | `PID_XBOXUK` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
| `0x3027` | PID_XBOXUKUSB | candidate-readonly | yes | no | no | blocked/no_runtime |
|
| `0x3027` | `PID_XBOXUKUSB` | read-only candidate | yes | no | no | blocked/no_runtime |
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
# Wave 2 Results
|
# Wave 2 Results
|
||||||
|
|
||||||
## Scope
|
## Outcome
|
||||||
- Expansion cohort: 12 additional popularity-first PIDs.
|
|
||||||
- Evidence mode: static/decompiler-only.
|
|
||||||
- Support result: all 12 moved to `candidate-readonly` with detect/diag-only behavior.
|
|
||||||
|
|
||||||
## Delivered
|
- twelve additional PIDs were expanded through sanitized static evidence
|
||||||
1. Manual, source-controlled dossier and matrix updates (no script-generated artifacts).
|
- all twelve remained read-only candidates
|
||||||
2. Wave 2 dossier set (`core_diag`, `mode_or_profile_read`, `firmware_preflight`) for each of 12 PIDs.
|
- no new family was promoted without runtime and hardware confirmation
|
||||||
3. `pid_matrix.csv` updated with Wave 2 candidate-readonly tiering.
|
|
||||||
4. `command_matrix.csv` updated with Wave 2 dossier-linked rows and explicit promotion gates.
|
|
||||||
5. `evidence_index.csv` updated from current candidate-readonly set.
|
|
||||||
6. Governance verification completed for variables, labels, and required checks.
|
|
||||||
|
|
||||||
## Deferred
|
## Deliverables
|
||||||
- Runtime trace evidence for Wave 2 cohort.
|
|
||||||
- Hardware read/write/readback confirmation for Wave 2 cohort.
|
|
||||||
- Any promotion to `full` for Wave 2 cohort.
|
|
||||||
|
|
||||||
## Exit Status
|
- updated dossiers and matrices
|
||||||
- Wave 2 static-only deliverables: complete.
|
- refreshed evidence index
|
||||||
- Promotion status: blocked pending runtime + hardware signals.
|
- governance and release-gate verification
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- runtime traces
|
||||||
|
- hardware confirmation
|
||||||
|
- any promotion to full support
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
# Wave 2 Runtime/Hardware Intake (Prepared, Deferred)
|
# Wave 2 Runtime Intake
|
||||||
|
|
||||||
## Purpose
|
This note describes the evidence still needed to move Wave 2 devices beyond static-only confidence.
|
||||||
Define exactly what sanitized runtime/hardware evidence is needed to move Wave 2 devices beyond static-only dossiers.
|
|
||||||
|
|
||||||
## Required Submission Data
|
## Required Submission Data
|
||||||
Every submission must include:
|
|
||||||
1. VID/PID in hex.
|
|
||||||
2. Firmware version.
|
|
||||||
3. Operation attempted.
|
|
||||||
4. Sanitized request structure.
|
|
||||||
5. Sanitized response structure.
|
|
||||||
6. Reproducibility notes (OS, transport, retries, success/failure rate).
|
|
||||||
|
|
||||||
## Sanitization Rules
|
1. VID/PID
|
||||||
Allowed content:
|
2. firmware version
|
||||||
- byte-layout summaries,
|
3. operation attempted
|
||||||
- command/response shape descriptions,
|
4. sanitized request structure
|
||||||
- validation predicates,
|
5. sanitized response structure
|
||||||
- timing/retry observations.
|
6. reproducibility notes
|
||||||
|
|
||||||
Forbidden content:
|
## Acceptance Rules
|
||||||
- raw decompiled code snippets,
|
|
||||||
- copied vendor constants blocks,
|
|
||||||
- copied source fragments from official binaries/tools.
|
|
||||||
|
|
||||||
## Evidence Acceptance Checklist
|
- no copied vendor code or decompiled snippets
|
||||||
1. VID/PID and firmware fields are present.
|
- failure signatures must map to stable categories
|
||||||
2. Request/response structure is sanitized and technically complete.
|
- the report must be repeatable enough for an independent rerun
|
||||||
3. Failure signatures are mapped to stable categories (`timeout`, `malformed`, `unsupported`, `invalid_signature`).
|
|
||||||
4. Repro steps are clear enough for independent rerun.
|
|
||||||
5. No forbidden raw-source content appears.
|
|
||||||
|
|
||||||
## Promotion Readiness Mapping
|
## Promotion Readiness
|
||||||
A PID is promotion-eligible only when all are true:
|
|
||||||
1. Static dossiers complete.
|
A PID is promotion-ready only when static, runtime, and hardware evidence all exist together.
|
||||||
2. Runtime traces accepted from at least 2 independent runs.
|
|
||||||
3. Hardware read/write/readback validation passes on owned fixture(s).
|
|
||||||
|
|||||||
69
sdk/Cargo.lock
generated
69
sdk/Cargo.lock
generated
@@ -161,10 +161,14 @@ dependencies = [
|
|||||||
"bitdo_proto",
|
"bitdo_proto",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm 0.29.0",
|
"crossterm 0.29.0",
|
||||||
|
"fuzzy-matcher",
|
||||||
|
"insta",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -316,6 +320,18 @@ dependencies = [
|
|||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -558,6 +574,12 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -574,6 +596,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -643,6 +671,15 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -994,6 +1031,18 @@ dependencies = [
|
|||||||
"rustversion",
|
"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]]
|
[[package]]
|
||||||
name = "instability"
|
name = "instability"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@@ -1196,6 +1245,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"bitdo_app_core",
|
"bitdo_app_core",
|
||||||
|
"bitdo_proto",
|
||||||
"bitdo_tui",
|
"bitdo_tui",
|
||||||
"clap",
|
"clap",
|
||||||
"predicates",
|
"predicates",
|
||||||
@@ -1785,6 +1835,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1894,6 +1950,19 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "termtree"
|
name = "termtree"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ resolver = "2"
|
|||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.0.1-rc.4"
|
||||||
license = "BSD-3-Clause"
|
license = "BSD-3-Clause"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -31,3 +31,6 @@ ratatui = "0.29"
|
|||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
fuzzy-matcher = "0.3"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
insta = "1.43"
|
||||||
|
|||||||
177
sdk/README.md
177
sdk/README.md
@@ -1,133 +1,90 @@
|
|||||||
# OpenBitdo SDK
|
# OpenBitdo SDK
|
||||||
|
|
||||||
OpenBitdo SDK includes:
|
This workspace contains the OpenBitdo runtime, protocol layer, and release packaging scripts.
|
||||||
- `bitdo_proto`: protocol/transport/session library
|
|
||||||
- `bitdo_app_core`: shared firmware-first workflow and policy layer
|
## Crates
|
||||||
- `bitdo_tui`: Ratatui/Crossterm terminal app
|
|
||||||
- `openbitdo`: beginner-first launcher (`openbitdo` starts guided TUI)
|
- `bitdo_proto`: command registry, transport, session, and diagnostics behavior
|
||||||
|
- `bitdo_app_core`: firmware policy, device workflows, and support-tier gating
|
||||||
|
- `bitdo_tui`: terminal UI, app state, runtime loop, persistence, and headless API
|
||||||
|
- `openbitdo`: beginner-first launcher binary
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
From `cleanroom/sdk`:
|
||||||
|
|
||||||
## Build
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --workspace
|
cargo build --workspace
|
||||||
```
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
## Test
|
|
||||||
```bash
|
|
||||||
cargo test --workspace --all-targets
|
cargo test --workspace --all-targets
|
||||||
```
|
|
||||||
|
|
||||||
## Guard
|
|
||||||
```bash
|
|
||||||
./scripts/cleanroom_guard.sh
|
./scripts/cleanroom_guard.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Hardware smoke report
|
## Local Run
|
||||||
```bash
|
|
||||||
./scripts/run_hardware_smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## TUI app examples (`openbitdo`)
|
|
||||||
```bash
|
```bash
|
||||||
|
cargo run -p openbitdo --
|
||||||
cargo run -p openbitdo -- --mock
|
cargo run -p openbitdo -- --mock
|
||||||
```
|
```
|
||||||
|
|
||||||
## Beginner-first behavior
|
`openbitdo` intentionally exposes a single interactive CLI surface.
|
||||||
- launch with no subcommands
|
Headless automation remains available through the Rust API in `bitdo_tui`.
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/package-linux.sh v0.0.1-rc.1 x86_64
|
./scripts/package-linux.sh v0.0.0-local x86_64
|
||||||
./scripts/package-linux.sh v0.0.1-rc.1 aarch64
|
./scripts/package-linux.sh v0.0.0-local aarch64
|
||||||
./scripts/package-macos.sh v0.0.1-rc.1 arm64 aarch64-apple-darwin
|
./scripts/package-macos.sh v0.0.0-local arm64 aarch64-apple-darwin
|
||||||
```
|
```
|
||||||
|
|
||||||
Packaging outputs use:
|
Outputs:
|
||||||
- `openbitdo-<version>-<os>-<arch>.tar.gz`
|
|
||||||
- `openbitdo-<version>-<os>-<arch>` standalone binary
|
|
||||||
- `.sha256` checksum file for each artifact
|
|
||||||
- macOS arm64 additionally emits `.pkg` (unsigned/ad-hoc for RC)
|
|
||||||
|
|
||||||
## Release Workflow
|
- `openbitdo-<version>-linux-x86_64.tar.gz`
|
||||||
- CI checks remain in `.github/workflows/ci.yml`.
|
- `openbitdo-<version>-linux-aarch64.tar.gz`
|
||||||
- Tag-based release workflow is in `.github/workflows/release.yml`.
|
- `openbitdo-<version>-macos-arm64.tar.gz`
|
||||||
- Release tags must originate from `main`.
|
- standalone binaries for each packaged target
|
||||||
- `v0.0.1-rc.1` style tags publish GitHub pre-releases.
|
- `.sha256` files for every artifact
|
||||||
- Release notes are sourced from `/Users/brooklyn/data/8bitdo/cleanroom/CHANGELOG.md`.
|
- macOS `.pkg` from `pkgbuild`
|
||||||
- Package-manager publish runs only after release assets are published.
|
|
||||||
|
|
||||||
## Public RC Gate
|
Current macOS packaging remains unsigned and non-notarized by design.
|
||||||
- No open GitHub issues with label `release-blocker`.
|
|
||||||
- Scope-completeness gate:
|
|
||||||
- JP108 RC scope is dedicated mapping only (`A/B/K1-K8`).
|
|
||||||
- Ultimate2 RC scope is expanded mapping for required fields only.
|
|
||||||
- Clean-tree requirement from `/Users/brooklyn/data/8bitdo/cleanroom/RC_CHECKLIST.md` must be satisfied before tagging.
|
|
||||||
|
|
||||||
## Distribution Prep
|
## Release Flow
|
||||||
- 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 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`
|
|
||||||
- AUR publish workflow:
|
|
||||||
- `/Users/brooklyn/data/8bitdo/cleanroom/.github/workflows/aur-publish.yml`
|
|
||||||
- gated by `AUR_PUBLISH_ENABLED=1`
|
|
||||||
- Homebrew publish path:
|
|
||||||
- `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
|
|
||||||
- notarization required for `v0.1.0`
|
|
||||||
|
|
||||||
## CI Gates
|
1. Tag from `main` using a `v*` tag.
|
||||||
- required:
|
2. `release.yml` verifies CI, secrets, and release blockers.
|
||||||
- `guard`
|
3. Linux and macOS artifacts are built and uploaded.
|
||||||
- `test`
|
4. GitHub prerelease assets are published from those artifacts.
|
||||||
- `tui-smoke-test`
|
5. AUR and Homebrew metadata are rendered from published release assets.
|
||||||
- `aur-validate`
|
6. AUR and Homebrew publication run only when their repo-variable gates are enabled.
|
||||||
- `build-macos-arm64`
|
|
||||||
|
## Package Manager Publishing
|
||||||
|
|
||||||
|
- AUR workflow: `.github/workflows/aur-publish.yml`
|
||||||
|
- Homebrew workflow: `.github/workflows/homebrew-publish.yml`
|
||||||
|
- Release metadata renderer: `packaging/scripts/render_release_metadata.sh`
|
||||||
|
- AUR source of truth:
|
||||||
|
- tracked package metadata in `packaging/aur/openbitdo-bin`
|
||||||
|
- template in `packaging/aur/openbitdo-bin/PKGBUILD.tmpl`
|
||||||
|
- Homebrew source of truth:
|
||||||
|
- template in `packaging/homebrew/Formula/openbitdo.rb.tmpl`
|
||||||
|
- published tap repo `bybrooklyn/homebrew-openbitdo`
|
||||||
|
|
||||||
|
Current repo-variable contract:
|
||||||
|
|
||||||
|
- `AUR_PUBLISH_ENABLED=1`
|
||||||
|
- `HOMEBREW_PUBLISH_ENABLED=1` when Homebrew publication is enabled
|
||||||
|
- `HOMEBREW_TAP_REPO=bybrooklyn/homebrew-openbitdo`
|
||||||
|
|
||||||
|
Required secrets:
|
||||||
|
|
||||||
|
- `AUR_USERNAME`
|
||||||
|
- `AUR_SSH_PRIVATE_KEY`
|
||||||
|
- `HOMEBREW_TAP_TOKEN`
|
||||||
|
|
||||||
|
## Docs Map
|
||||||
|
|
||||||
|
- Public project overview: [`../README.md`](../README.md)
|
||||||
|
- RC checklist: [`../RC_CHECKLIST.md`](../RC_CHECKLIST.md)
|
||||||
|
- Process docs: [`../process`](../process)
|
||||||
|
- Spec docs: [`../spec`](../spec)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use bitdo_proto::{
|
use bitdo_proto::{
|
||||||
device_profile_for, enumerate_hid_devices, BitdoErrorCode, DeviceSession, DiagProbeResult,
|
device_profile_for, enumerate_hid_devices, find_command, BitdoErrorCode, CommandId,
|
||||||
DiagSeverity, HidTransport, PidCapability, ProtocolFamily, SessionConfig, SupportEvidence,
|
DeviceSession, DiagProbeResult, DiagSeverity, HidTransport, PidCapability, ProtocolFamily,
|
||||||
SupportLevel, SupportTier, VidPid,
|
ResponseStatus, SessionConfig, SupportEvidence, SupportLevel, SupportTier, VidPid,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -348,6 +348,11 @@ impl OpenBitdoCore {
|
|||||||
pub fn beginner_diag_summary(&self, device: &AppDevice, diag: &DiagProbeResult) -> String {
|
pub fn beginner_diag_summary(&self, device: &AppDevice, diag: &DiagProbeResult) -> String {
|
||||||
let passed = diag.command_checks.iter().filter(|c| c.ok).count();
|
let passed = diag.command_checks.iter().filter(|c| c.ok).count();
|
||||||
let total = diag.command_checks.len();
|
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
|
let experimental_total = diag
|
||||||
.command_checks
|
.command_checks
|
||||||
.iter()
|
.iter()
|
||||||
@@ -381,23 +386,28 @@ impl OpenBitdoCore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_hint = if needs_attention > 0 {
|
let status_hint = if issue_total > 0 {
|
||||||
format!("Needs attention: {needs_attention} safety-critical diagnostic signal(s).")
|
format!("Issues: {issue_total} total, {needs_attention} need attention.")
|
||||||
} else {
|
} else {
|
||||||
"Needs attention: none.".to_owned()
|
"Issues: none.".to_owned()
|
||||||
};
|
};
|
||||||
let experimental_hint =
|
let experimental_hint =
|
||||||
format!("Experimental checks: {experimental_ok}/{experimental_total} passed.");
|
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 {
|
match device.support_tier {
|
||||||
SupportTier::Full => format!(
|
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!(
|
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!(
|
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 {
|
fn mock_diag_probe(target: VidPid) -> DiagProbeResult {
|
||||||
let profile = device_profile_for(target);
|
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 {
|
DiagProbeResult {
|
||||||
target,
|
target,
|
||||||
profile_name: profile.name,
|
profile_name: profile.name,
|
||||||
@@ -1799,35 +1833,203 @@ fn mock_diag_probe(target: VidPid) -> DiagProbeResult {
|
|||||||
capability: profile.capability,
|
capability: profile.capability,
|
||||||
evidence: profile.evidence,
|
evidence: profile.evidence,
|
||||||
transport_ready: true,
|
transport_ready: true,
|
||||||
command_checks: vec![
|
command_checks,
|
||||||
bitdo_proto::DiagCommandStatus {
|
}
|
||||||
command: bitdo_proto::CommandId::GetPid,
|
}
|
||||||
ok: true,
|
|
||||||
confidence: bitdo_proto::EvidenceConfidence::Confirmed,
|
fn mock_diag_commands_for(profile: &bitdo_proto::DeviceProfile, target: VidPid) -> Vec<CommandId> {
|
||||||
is_experimental: false,
|
const SAFE_READ_ORDER: &[CommandId] = &[
|
||||||
severity: bitdo_proto::DiagSeverity::Ok,
|
CommandId::GetPid,
|
||||||
error_code: None,
|
CommandId::GetReportRevision,
|
||||||
detail: "ok".to_owned(),
|
CommandId::GetMode,
|
||||||
},
|
CommandId::GetModeAlt,
|
||||||
bitdo_proto::DiagCommandStatus {
|
CommandId::GetControllerVersion,
|
||||||
command: bitdo_proto::CommandId::GetControllerVersion,
|
CommandId::GetSuperButton,
|
||||||
ok: true,
|
CommandId::Idle,
|
||||||
confidence: bitdo_proto::EvidenceConfidence::Confirmed,
|
CommandId::Version,
|
||||||
is_experimental: false,
|
CommandId::ReadProfile,
|
||||||
severity: bitdo_proto::DiagSeverity::Ok,
|
CommandId::Jp108ReadDedicatedMappings,
|
||||||
error_code: None,
|
CommandId::Jp108ReadFeatureFlags,
|
||||||
detail: "ok".to_owned(),
|
CommandId::Jp108ReadVoice,
|
||||||
},
|
CommandId::U2GetCurrentSlot,
|
||||||
bitdo_proto::DiagCommandStatus {
|
CommandId::U2ReadConfigSlot,
|
||||||
command: bitdo_proto::CommandId::GetSuperButton,
|
CommandId::U2ReadButtonMap,
|
||||||
ok: true,
|
];
|
||||||
confidence: bitdo_proto::EvidenceConfidence::Inferred,
|
|
||||||
is_experimental: true,
|
SAFE_READ_ORDER
|
||||||
severity: bitdo_proto::DiagSeverity::Ok,
|
.iter()
|
||||||
error_code: None,
|
.copied()
|
||||||
detail: "ok".to_owned(),
|
.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 confidence: EvidenceConfidence,
|
||||||
pub is_experimental: bool,
|
pub is_experimental: bool,
|
||||||
pub severity: DiagSeverity,
|
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 error_code: Option<BitdoErrorCode>,
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
|
pub parsed_facts: BTreeMap<String, u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -210,60 +216,12 @@ impl<T: Transport> DeviceSession<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn diag_probe(&mut self) -> DiagProbeResult {
|
pub fn diag_probe(&mut self) -> DiagProbeResult {
|
||||||
let target_pid = self.target.pid;
|
let checks_to_run = self.diag_commands_to_run();
|
||||||
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 mut checks = Vec::with_capacity(checks_to_run.len());
|
let mut checks = Vec::with_capacity(checks_to_run.len());
|
||||||
for (cmd, runtime_policy, confidence) in checks_to_run {
|
for (command, runtime_policy, confidence) in checks_to_run {
|
||||||
match self.send_command(cmd, None) {
|
checks.push(self.run_diag_check(command, runtime_policy, confidence));
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let transport_ready = checks.iter().any(|check| check.ok);
|
||||||
|
|
||||||
DiagProbeResult {
|
DiagProbeResult {
|
||||||
target: self.target,
|
target: self.target,
|
||||||
@@ -273,11 +231,193 @@ impl<T: Transport> DeviceSession<T> {
|
|||||||
protocol_family: self.profile.protocol_family,
|
protocol_family: self.profile.protocol_family,
|
||||||
capability: self.profile.capability,
|
capability: self.profile.capability,
|
||||||
evidence: self.profile.evidence,
|
evidence: self.profile.evidence,
|
||||||
transport_ready: true,
|
transport_ready,
|
||||||
command_checks: checks,
|
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> {
|
pub fn get_mode(&mut self) -> Result<ModeState> {
|
||||||
let resp = self.send_command(CommandId::GetMode, None)?;
|
let resp = self.send_command(CommandId::GetMode, None)?;
|
||||||
if let Some(mode) = resp.parsed_fields.get("mode").copied() {
|
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]]);
|
let pid = u16::from_le_bytes([response[22], response[23]]);
|
||||||
parsed.insert("detected_pid".to_owned(), pid as u32);
|
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 => {
|
CommandId::GetMode | CommandId::GetModeAlt if response.len() >= 6 => {
|
||||||
parsed.insert("mode".to_owned(), response[5] as u32);
|
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
|
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 }
|
ratatui = { workspace = true }
|
||||||
crossterm = { workspace = true }
|
crossterm = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
fuzzy-matcher = { workspace = true }
|
||||||
|
unicode-width = { workspace = true }
|
||||||
bitdo_proto = { path = "../bitdo_proto" }
|
bitdo_proto = { path = "../bitdo_proto" }
|
||||||
bitdo_app_core = { path = "../bitdo_app_core" }
|
bitdo_app_core = { path = "../bitdo_app_core" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
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,28 @@
|
|||||||
|
---
|
||||||
|
source: crates/bitdo_tui/src/tests.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
╭Session───────────────────────────────────────────────────────────────────────╮
|
||||||
|
│OpenBitDo Dashboard • 3 devices • reports fail-only • safe │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Search filter────────────────╮╭Device supported─────╮╭Actions Enter/click──╮
|
||||||
|
│Search active ││Ultimate2 2dc8:5209 ││› Refresh • look for│
|
||||||
|
╰──────────────────────────────╯│Support: Supported ││ Diagnose • run saf│
|
||||||
|
╭Controllers detected─────────╮│Protocol: Standard64 ││ Recommended Update │
|
||||||
|
│› 2dc8:5209 Ultimate2 ││Evidence: Confirmed ││ Edit Mapping • cha│
|
||||||
|
│supported • standard64 • confi││ ││ Settings • report │
|
||||||
|
│ 2dc8:6009 Ultimate ││Capabilities ││ Quit • close OpenB│
|
||||||
|
│read-only • standard64 • infer││• firmware updates ││ │
|
||||||
|
│ 2dc8:901a Candidate ││• profile read and wri│╰──────────────────────╯
|
||||||
|
│read-only • unknown • untested││• mode switching │╭Activity events──────╮
|
||||||
|
│ ││• JP108 dedicated mapp││ │
|
||||||
|
│ ││• Ultimate 2 slot and ││ │
|
||||||
|
│ ││ ││ │
|
||||||
|
│ ││ ││ │
|
||||||
|
│ ││ ││ │
|
||||||
|
│ ││ ││ │
|
||||||
|
╰──────────────────────────────╯╰──────────────────────╯╰──────────────────────╯
|
||||||
|
╭Status────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│Ready │
|
||||||
|
│Ultimate2 • click a device or action • arrows, Enter, Esc, and q still work │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
source: crates/bitdo_tui/src/tests.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│3/5 passed • 2 issues • 2 experimental │
|
||||||
|
│Tier: supported • 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 for update or mapp… │
|
||||||
|
│ ││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 │
|
||||||
|
│run the safe checks again ││save a shareable support re… ││return to dashboard │
|
||||||
|
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: crates/bitdo_tui/src/tests.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
╭Session───────────────────────────────────────────────────────────────────────╮
|
||||||
|
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Diagnostics summary──────────────────────────────────────────────────────────╮
|
||||||
|
│3/5 passed • 2 issues • 2 experimental │
|
||||||
|
│Tier: supported • 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 for update or mapping if this device still… │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Run Again───────────────╮╭Save Report──────────────╮╭Back────────────────────╮
|
||||||
|
│Run Again ││Save Report ││Back │
|
||||||
|
│run the safe checks … ││save a shareable supp… ││return to dashboard │
|
||||||
|
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
source: crates/bitdo_tui/src/tests.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│3/5 passed • 2 issues • 2 experimental │
|
||||||
|
│Tier: supported • 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 for update or mapp… │
|
||||||
|
│ ││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 │
|
||||||
|
│run the safe checks again ││save a shareable support re… ││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,34 @@
|
|||||||
|
---
|
||||||
|
source: crates/bitdo_tui/src/tests.rs
|
||||||
|
expression: rendered
|
||||||
|
---
|
||||||
|
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│OpenBitDo Workflow • Ready • reports fail-only • safe │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Safety Check status and intent───────────────────────────────────────────────────────────────────╮
|
||||||
|
│Safety Check Workflow reviewing update safety │
|
||||||
|
│ │
|
||||||
|
│Ready to confirm transfer │
|
||||||
|
│ │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭Details workflow context───────────────────────────────╮╭Progress transfer state────────────────╮
|
||||||
|
│Ready to confirm transfer ││█████ │
|
||||||
|
│ ││█████ 12% │
|
||||||
|
│ │╰────────────────────────────────────────╯
|
||||||
|
│ │╭Context current session────────────────╮
|
||||||
|
│ ││Current stage: reviewing update safety │
|
||||||
|
│ ││Progress: 12% │
|
||||||
|
│ ││Report policy: failure_only │
|
||||||
|
│ ││Ready │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
╰────────────────────────────────────────────────────────╯╰────────────────────────────────────────╯
|
||||||
|
╭Confirm────────────────────────╮╭Cancel─────────────────────────╮╭Back───────────────────────────╮
|
||||||
|
│Confirm ││Cancel ││Back │
|
||||||
|
│acknowledge risk and start … ││stop and discard this step ││leave this screen │
|
||||||
|
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
|
||||||
@@ -1,370 +1,654 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::support_report::report_subject_token;
|
use crate::app::action::QuickAction;
|
||||||
use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig};
|
use crate::app::event::AppEvent;
|
||||||
use bitdo_proto::SupportLevel;
|
use crate::app::reducer::reduce;
|
||||||
|
use crate::app::state::{
|
||||||
#[test]
|
AppState, DiagnosticsFilter, DiagnosticsState, MappingDraftState, Screen, TaskMode,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123");
|
use crate::persistence::ui_state::{load_ui_state, persist_ui_state};
|
||||||
|
use crate::runtime::effect_executor::execute_effect;
|
||||||
let without_serial = AppDevice {
|
use bitdo_app_core::{DedicatedButtonId, DedicatedButtonMapping, OpenBitdoCoreConfig};
|
||||||
serial: None,
|
use bitdo_proto::{
|
||||||
..with_serial
|
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]
|
#[test]
|
||||||
fn launch_options_default_to_failure_only_reports() {
|
fn mapping_draft_undo_and_reset() {
|
||||||
let opts = TuiLaunchOptions::default();
|
let mut state = AppState::new(&UiLaunchOptions::default());
|
||||||
assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly);
|
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]
|
#[test]
|
||||||
fn blocked_panel_text_matches_support_tier() {
|
fn settings_schema_v2_roundtrip() {
|
||||||
let mut app = TuiApp::default();
|
let path = std::env::temp_dir().join("bitdo-tui-ui-state-v2.toml");
|
||||||
app.refresh_devices(vec![AppDevice {
|
persist_ui_state(
|
||||||
vid_pid: VidPid::new(0x2dc8, 0x2100),
|
&path,
|
||||||
name: "Candidate".to_owned(),
|
true,
|
||||||
support_level: SupportLevel::DetectOnly,
|
ReportSaveMode::Always,
|
||||||
support_tier: SupportTier::CandidateReadOnly,
|
"ultimate".to_owned(),
|
||||||
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
|
DashboardLayoutMode::Compact,
|
||||||
capability: bitdo_proto::PidCapability {
|
PanelFocus::QuickActions,
|
||||||
supports_mode: true,
|
)
|
||||||
supports_profile_rw: true,
|
.expect("persist");
|
||||||
supports_boot: false,
|
|
||||||
supports_firmware: false,
|
let loaded = load_ui_state(&path).expect("load");
|
||||||
supports_jp108_dedicated_map: false,
|
assert_eq!(loaded.schema_version, 2);
|
||||||
supports_u2_slot_config: false,
|
assert!(loaded.advanced_mode);
|
||||||
supports_u2_button_map: false,
|
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,
|
evidence: bitdo_proto::SupportEvidence::Inferred,
|
||||||
serial: None,
|
serial: None,
|
||||||
connected: true,
|
connected: true,
|
||||||
}]);
|
},
|
||||||
let selected = app.selected_device().expect("selected");
|
bitdo_app_core::AppDevice {
|
||||||
let text = blocked_action_panel_text(selected);
|
vid_pid: VidPid::new(0x2dc8, 0x901a),
|
||||||
assert!(text.contains("blocked"));
|
name: "Candidate".to_owned(),
|
||||||
assert!(text.contains("Status shown as Blocked"));
|
support_level: bitdo_proto::SupportLevel::DetectOnly,
|
||||||
assert_eq!(beginner_status_label(selected), "Blocked");
|
support_tier: bitdo_proto::SupportTier::CandidateReadOnly,
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
|
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
|
||||||
capability: bitdo_proto::PidCapability::identify_only(),
|
capability: bitdo_proto::PidCapability::identify_only(),
|
||||||
evidence: bitdo_proto::SupportEvidence::Untested,
|
evidence: bitdo_proto::SupportEvidence::Untested,
|
||||||
serial: None,
|
serial: None,
|
||||||
connected: true,
|
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;
|
);
|
||||||
|
state.event_log.clear();
|
||||||
assert!(result.is_err());
|
state.status_line = "Ready".to_owned();
|
||||||
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn sample_diagnostics_state(report_path: Option<PathBuf>) -> DiagnosticsState {
|
||||||
async fn run_tui_app_no_ui_full_support_completes() {
|
DiagnosticsState {
|
||||||
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
result: sample_diagnostics_result(),
|
||||||
mock_mode: true,
|
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(),
|
||||||
default_chunk_size: 16,
|
selected_check_index: 0,
|
||||||
progress_interval_ms: 1,
|
active_filter: DiagnosticsFilter::All,
|
||||||
..Default::default()
|
latest_report_path: report_path,
|
||||||
});
|
}
|
||||||
|
|
||||||
run_tui_app(
|
|
||||||
core,
|
|
||||||
TuiLaunchOptions {
|
|
||||||
no_ui: true,
|
|
||||||
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("run app");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn sample_diagnostics_result() -> DiagProbeResult {
|
||||||
async fn tui_flow_with_manual_path_completes() {
|
DiagProbeResult {
|
||||||
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
target: VidPid::new(0x2dc8, 0x5209),
|
||||||
mock_mode: true,
|
profile_name: "Ultimate2".to_owned(),
|
||||||
default_chunk_size: 16,
|
support_level: bitdo_proto::SupportLevel::Full,
|
||||||
progress_interval_ms: 1,
|
support_tier: bitdo_proto::SupportTier::Full,
|
||||||
..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,
|
|
||||||
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
|
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
|
||||||
capability: bitdo_proto::PidCapability::full(),
|
capability: bitdo_proto::PidCapability::full(),
|
||||||
evidence: bitdo_proto::SupportEvidence::Confirmed,
|
evidence: bitdo_proto::SupportEvidence::Confirmed,
|
||||||
serial: Some("RPT-1".to_owned()),
|
transport_ready: true,
|
||||||
connected: true,
|
command_checks: vec![
|
||||||
};
|
diag_check(
|
||||||
|
CommandId::GetPid,
|
||||||
let report_path = persist_support_report(
|
DiagCheckFixture {
|
||||||
"diag-probe",
|
ok: true,
|
||||||
Some(&device),
|
confidence: EvidenceConfidence::Confirmed,
|
||||||
"ok",
|
is_experimental: false,
|
||||||
"all checks passed".to_owned(),
|
severity: DiagSeverity::Ok,
|
||||||
None,
|
error_code: None,
|
||||||
None,
|
detail: "detected pid 0x5209",
|
||||||
)
|
parsed_facts: [("detected_pid", 0x5209)].into_iter().collect(),
|
||||||
.await
|
},
|
||||||
.expect("report path");
|
),
|
||||||
|
diag_check(
|
||||||
assert_eq!(
|
CommandId::GetMode,
|
||||||
report_path.extension().and_then(|s| s.to_str()),
|
DiagCheckFixture {
|
||||||
Some("toml")
|
ok: true,
|
||||||
);
|
confidence: EvidenceConfidence::Confirmed,
|
||||||
let _ = tokio::fs::remove_file(report_path).await;
|
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]
|
struct DiagCheckFixture<'a> {
|
||||||
async fn update_action_enters_jp108_wizard_for_jp108_device() {
|
ok: bool,
|
||||||
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
confidence: EvidenceConfidence,
|
||||||
mock_mode: true,
|
is_experimental: bool,
|
||||||
..Default::default()
|
severity: DiagSeverity,
|
||||||
});
|
error_code: Option<BitdoErrorCode>,
|
||||||
let mut app = TuiApp::default();
|
detail: &'a str,
|
||||||
app.refresh_devices(core.list_devices().await.expect("devices"));
|
parsed_facts: BTreeMap<&'a str, u32>,
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn diag_check(command: CommandId, fixture: DiagCheckFixture<'_>) -> DiagCommandStatus {
|
||||||
async fn update_action_enters_u2_wizard_for_ultimate2_device() {
|
DiagCommandStatus {
|
||||||
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
command,
|
||||||
mock_mode: true,
|
ok: fixture.ok,
|
||||||
..Default::default()
|
confidence: fixture.confidence,
|
||||||
});
|
is_experimental: fixture.is_experimental,
|
||||||
let mut app = TuiApp::default();
|
severity: fixture.severity,
|
||||||
app.refresh_devices(core.list_devices().await.expect("devices"));
|
attempts: 1,
|
||||||
let u2_idx = app
|
validator: format!("test:{command:?}"),
|
||||||
.devices
|
response_status: if fixture.ok {
|
||||||
.iter()
|
ResponseStatus::Ok
|
||||||
.position(|d| d.vid_pid.pid == 0x6012)
|
} else {
|
||||||
.expect("u2 fixture");
|
ResponseStatus::Invalid
|
||||||
app.select_index(u2_idx);
|
},
|
||||||
app.state = TuiWorkflowState::Home;
|
bytes_written: 64,
|
||||||
|
bytes_read: if fixture.ok { 64 } else { 8 },
|
||||||
let mut terminal = None;
|
error_code: fixture.error_code,
|
||||||
let mut events = None;
|
detail: fixture.detail.to_owned(),
|
||||||
let opts = TuiLaunchOptions::default();
|
parsed_facts: fixture
|
||||||
execute_home_action(
|
.parsed_facts
|
||||||
&core,
|
.into_iter()
|
||||||
&mut terminal,
|
.map(|(key, value)| (key.to_owned(), value))
|
||||||
&mut app,
|
.collect(),
|
||||||
&opts,
|
}
|
||||||
&mut events,
|
|
||||||
HomeAction::Update,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("execute");
|
|
||||||
|
|
||||||
assert_eq!(app.state, TuiWorkflowState::U2CoreProfile);
|
|
||||||
assert!(app.u2_profile.is_some());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn render_state(state: &mut AppState, width: u16, height: u16) -> String {
|
||||||
async fn device_flow_backup_apply_sets_backup_id() {
|
state.set_layout_from_size(width, height);
|
||||||
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
let backend = TestBackend::new(width, height);
|
||||||
mock_mode: true,
|
let mut terminal = Terminal::new(backend).expect("terminal");
|
||||||
..Default::default()
|
terminal
|
||||||
});
|
.draw(|frame| {
|
||||||
let mut app = TuiApp::default();
|
let _ = crate::ui::layout::render(frame, state);
|
||||||
app.refresh_devices(core.list_devices().await.expect("devices"));
|
})
|
||||||
let jp108_idx = app
|
.expect("draw");
|
||||||
.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"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut terminal = None;
|
let backend = terminal.backend();
|
||||||
let mut events = None;
|
let buffer = backend.buffer();
|
||||||
let opts = TuiLaunchOptions::default();
|
let mut lines = Vec::new();
|
||||||
execute_device_flow_action(
|
for y in 0..height {
|
||||||
&core,
|
let mut line = String::new();
|
||||||
&mut terminal,
|
for x in 0..width {
|
||||||
&mut app,
|
line.push_str(buffer[(x, y)].symbol());
|
||||||
&opts,
|
}
|
||||||
&mut events,
|
lines.push(line.trim_end().to_owned());
|
||||||
DeviceFlowAction::BackupApply,
|
}
|
||||||
)
|
|
||||||
.await
|
lines.join("\n")
|
||||||
.expect("apply");
|
|
||||||
|
|
||||||
assert!(app.latest_backup.is_some());
|
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
423
sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs
Normal file
423
sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
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 three panels or keep using click, arrows, and Enter"
|
||||||
|
}
|
||||||
|
DashboardLayoutMode::Wide => {
|
||||||
|
"click a device or action • arrows, Enter, Esc, and q still work"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 name or USB ID".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(
|
||||||
|
"This device is still read-only here. Safe diagnostics work, but mapping and update 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 => "look for connected controllers",
|
||||||
|
crate::app::action::QuickAction::Diagnose => {
|
||||||
|
"run safe diagnostics and build a support summary"
|
||||||
|
}
|
||||||
|
crate::app::action::QuickAction::RecommendedUpdate => {
|
||||||
|
"download and stage a verified firmware update"
|
||||||
|
}
|
||||||
|
crate::app::action::QuickAction::EditMappings => {
|
||||||
|
"change supported buttons on supported devices"
|
||||||
|
}
|
||||||
|
crate::app::action::QuickAction::Settings => "report saving and interface preferences",
|
||||||
|
crate::app::action::QuickAction::Quit => "close OpenBitdo",
|
||||||
|
_ => "available",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
|
||||||
|
match tier {
|
||||||
|
bitdo_proto::SupportTier::Full => "Supported",
|
||||||
|
bitdo_proto::SupportTier::CandidateReadOnly => "Read-only candidate",
|
||||||
|
bitdo_proto::SupportTier::DetectOnly => "Detection only",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn support_tier_short(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 capability_lines(device: &bitdo_app_core::AppDevice) -> Vec<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
if device.capability.supports_firmware {
|
||||||
|
lines.push("• firmware updates".to_owned());
|
||||||
|
}
|
||||||
|
if device.capability.supports_profile_rw {
|
||||||
|
lines.push("• profile read and write".to_owned());
|
||||||
|
}
|
||||||
|
if device.capability.supports_mode {
|
||||||
|
lines.push("• mode switching".to_owned());
|
||||||
|
}
|
||||||
|
if device.capability.supports_jp108_dedicated_map {
|
||||||
|
lines.push("• JP108 dedicated mapping".to_owned());
|
||||||
|
}
|
||||||
|
if device.capability.supports_u2_button_map || device.capability.supports_u2_slot_config {
|
||||||
|
lines.push("• Ultimate 2 slot and mapping".to_owned());
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push("• detection 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 => "standard64",
|
||||||
|
bitdo_proto::ProtocolFamily::DInput => "dinput",
|
||||||
|
bitdo_proto::ProtocolFamily::JpHandshake => "jp",
|
||||||
|
bitdo_proto::ProtocolFamily::Unknown => "unknown",
|
||||||
|
_ => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_short(evidence: bitdo_proto::SupportEvidence) -> &'static str {
|
||||||
|
match evidence {
|
||||||
|
bitdo_proto::SupportEvidence::Confirmed => "confirmed",
|
||||||
|
bitdo_proto::SupportEvidence::Inferred => "inferred",
|
||||||
|
bitdo_proto::SupportEvidence::Untested => "untested",
|
||||||
|
}
|
||||||
|
}
|
||||||
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 => "run the safe checks again",
|
||||||
|
QuickAction::SaveReport => "save a shareable 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 for update or mapping if this device still needs work."
|
||||||
|
}
|
||||||
|
bitdo_proto::SupportTier::CandidateReadOnly => {
|
||||||
|
"Save or share the report. Update and mapping stay blocked until this device family is hardware-confirmed."
|
||||||
|
}
|
||||||
|
bitdo_proto::SupportTier::DetectOnly => {
|
||||||
|
"Use diagnostics only. This device is not ready for update or mapping flows."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => "supported",
|
||||||
|
bitdo_proto::SupportTier::CandidateReadOnly => "read-only candidate",
|
||||||
|
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 controls are on"
|
||||||
|
} else {
|
||||||
|
"Advanced controls are off"
|
||||||
|
},
|
||||||
|
crate::ui::theme::screen_title_style(),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Turn this on only if you want expert labels and extra workflow options.",
|
||||||
|
crate::ui::theme::subtle_style(),
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
.block(panel_block("Advanced", Some("toggle"), 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(
|
||||||
|
"Choose whether support reports save automatically after diagnostics or firmware work.",
|
||||||
|
crate::ui::theme::subtle_style(),
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
.block(panel_block("Reports", Some("save policy"), 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, filters, and report preferences persist when this path is available.",
|
||||||
|
crate::ui::theme::subtle_style(),
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
.block(panel_block("Status", Some("persistence"), 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 => "close OpenBitdo",
|
||||||
|
_ => "available",
|
||||||
|
}
|
||||||
|
}
|
||||||
189
sdk/crates/bitdo_tui/src/ui/screens/task.rs
Normal file
189
sdk/crates/bitdo_tui/src/ui/screens/task.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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) => "Safety Check",
|
||||||
|
Some(TaskMode::Updating) => "Update In Progress",
|
||||||
|
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("Choose a controller 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!(
|
||||||
|
"Transfer session: {:?}",
|
||||||
|
plan.session_id
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(format!("Chunk size: {} bytes", plan.chunk_size)));
|
||||||
|
lines.push(Line::from(format!("Total chunks: {}", plan.chunks_total)));
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
"Estimated transfer time: {}s",
|
||||||
|
plan.expected_seconds
|
||||||
|
)));
|
||||||
|
if !plan.warnings.is_empty() {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Safety notes",
|
||||||
|
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!("Current stage: {}", task_mode_caption(task.mode))),
|
||||||
|
Line::from(format!("Progress: {progress}%")),
|
||||||
|
Line::from(format!(
|
||||||
|
"Report policy: {}",
|
||||||
|
state.report_save_mode.as_str()
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
state.status_line.clone(),
|
||||||
|
crate::ui::theme::subtle_style(),
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![Line::from("Choose an action to see its workflow 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 => "running safe diagnostics",
|
||||||
|
TaskMode::Preflight => "reviewing update safety",
|
||||||
|
TaskMode::Updating => "sending verified firmware",
|
||||||
|
TaskMode::Final => "showing the final result",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task_action_caption(action: crate::app::action::QuickAction) -> &'static str {
|
||||||
|
match action {
|
||||||
|
crate::app::action::QuickAction::Confirm => "acknowledge risk and start the update",
|
||||||
|
crate::app::action::QuickAction::Cancel => "stop and discard this step",
|
||||||
|
crate::app::action::QuickAction::Back => "leave this screen",
|
||||||
|
_ => "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 }
|
tracing-subscriber = { workspace = true }
|
||||||
bitdo_app_core = { path = "../bitdo_app_core" }
|
bitdo_app_core = { path = "../bitdo_app_core" }
|
||||||
bitdo_tui = { path = "../bitdo_tui" }
|
bitdo_tui = { path = "../bitdo_tui" }
|
||||||
|
bitdo_proto = { path = "../bitdo_proto" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2.0"
|
assert_cmd = "2.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use bitdo_app_core::{signing_key_fingerprint_active_sha256, signing_key_fingerprint_next_sha256};
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -89,6 +89,12 @@ pub struct UserSettings {
|
|||||||
pub advanced_mode: bool,
|
pub advanced_mode: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub report_save_mode: ReportSaveMode,
|
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 {
|
impl Default for UserSettings {
|
||||||
@@ -97,12 +103,15 @@ impl Default for UserSettings {
|
|||||||
schema_version: default_settings_schema_version(),
|
schema_version: default_settings_schema_version(),
|
||||||
advanced_mode: false,
|
advanced_mode: false,
|
||||||
report_save_mode: ReportSaveMode::FailureOnly,
|
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 {
|
const fn default_settings_schema_version() -> u32 {
|
||||||
1
|
2
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_settings_path() -> PathBuf {
|
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")
|
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 {
|
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 {
|
if !settings.advanced_mode && settings.report_save_mode == ReportSaveMode::Off {
|
||||||
settings.report_save_mode = ReportSaveMode::FailureOnly;
|
settings.report_save_mode = ReportSaveMode::FailureOnly;
|
||||||
}
|
}
|
||||||
settings
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_user_settings(path: &Path, settings: &UserSettings) -> anyhow::Result<()> {
|
pub fn save_user_settings(path: &Path, settings: &UserSettings) -> anyhow::Result<()> {
|
||||||
@@ -194,16 +205,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_roundtrip_toml() {
|
fn settings_roundtrip_toml_v2() {
|
||||||
let tmp =
|
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 {
|
let settings = UserSettings {
|
||||||
schema_version: 1,
|
schema_version: 2,
|
||||||
advanced_mode: true,
|
advanced_mode: true,
|
||||||
report_save_mode: ReportSaveMode::Always,
|
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");
|
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);
|
assert_eq!(loaded, settings);
|
||||||
let _ = std::fs::remove_file(tmp);
|
let _ = std::fs::remove_file(tmp);
|
||||||
}
|
}
|
||||||
@@ -211,8 +225,35 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn missing_settings_uses_defaults() {
|
fn missing_settings_uses_defaults() {
|
||||||
let path = PathBuf::from("/tmp/openbitdo-nonexistent-settings.toml");
|
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!(!loaded.advanced_mode);
|
||||||
assert_eq!(loaded.report_save_mode, ReportSaveMode::FailureOnly);
|
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,12 +1,28 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bitdo_app_core::{OpenBitdoCore, OpenBitdoCoreConfig};
|
use bitdo_app_core::{OpenBitdoCore, OpenBitdoCoreConfig};
|
||||||
use bitdo_tui::{run_tui_app, TuiLaunchOptions};
|
use bitdo_tui::{run_ui, UiLaunchOptions};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use openbitdo::{load_user_settings, user_settings_path, BuildInfo};
|
use openbitdo::{load_user_settings, user_settings_path, BuildInfo, UserSettings};
|
||||||
|
|
||||||
|
const CLI_AFTER_HELP: &str = "\
|
||||||
|
Examples:
|
||||||
|
openbitdo
|
||||||
|
openbitdo --mock
|
||||||
|
|
||||||
|
Install:
|
||||||
|
Homebrew: brew tap bybrooklyn/openbitdo && brew install openbitdo
|
||||||
|
AUR: paru -S openbitdo-bin
|
||||||
|
Releases: download a tarball, then run bin/openbitdo
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
--mock starts the app without real hardware.
|
||||||
|
macOS packages are currently unsigned and non-notarized.
|
||||||
|
";
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "openbitdo")]
|
#[command(name = "openbitdo")]
|
||||||
#[command(about = "OpenBitdo beginner-first launcher")]
|
#[command(about = "Beginner-first 8BitDo controller utility")]
|
||||||
|
#[command(after_help = CLI_AFTER_HELP)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long, help = "Use mock transport/devices")]
|
#[arg(long, help = "Use mock transport/devices")]
|
||||||
mock: bool,
|
mock: bool,
|
||||||
@@ -15,9 +31,19 @@ struct Cli {
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let settings_path = user_settings_path();
|
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 {
|
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
|
||||||
mock_mode: cli.mock,
|
mock_mode: cli.mock,
|
||||||
@@ -25,9 +51,10 @@ async fn main() -> Result<()> {
|
|||||||
progress_interval_ms: 5,
|
progress_interval_ms: 5,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
run_tui_app(
|
|
||||||
|
run_ui(
|
||||||
core,
|
core,
|
||||||
TuiLaunchOptions {
|
UiLaunchOptions {
|
||||||
build_info: BuildInfo::current().to_tui_info(),
|
build_info: BuildInfo::current().to_tui_info(),
|
||||||
advanced_mode: settings.advanced_mode,
|
advanced_mode: settings.advanced_mode,
|
||||||
report_save_mode: settings.report_save_mode,
|
report_save_mode: settings.report_save_mode,
|
||||||
@@ -36,6 +63,7 @@ async fn main() -> Result<()> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +79,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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");
|
let err = Cli::try_parse_from(["openbitdo", "cmd"]).expect_err("must reject cmd");
|
||||||
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
|
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,35 @@ use assert_cmd::cargo::cargo_bin_cmd;
|
|||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_mentions_beginner_flow() {
|
fn help_mentions_single_command_surface() {
|
||||||
let mut cmd = cargo_bin_cmd!("openbitdo");
|
let mut cmd = cargo_bin_cmd!("openbitdo");
|
||||||
cmd.arg("--help")
|
cmd.arg("--help")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.success()
|
||||||
.stdout(predicate::str::contains("beginner-first"))
|
.stdout(predicate::str::contains("Usage: openbitdo [OPTIONS]"))
|
||||||
.stdout(predicate::str::contains("--mock"))
|
.stdout(predicate::str::contains("--mock"))
|
||||||
.stdout(predicate::str::contains("cmd").not());
|
.stdout(predicate::str::contains("Examples:"))
|
||||||
|
.stdout(predicate::str::contains(
|
||||||
|
"Homebrew: brew tap bybrooklyn/openbitdo && brew install openbitdo",
|
||||||
|
))
|
||||||
|
.stdout(predicate::str::contains(
|
||||||
|
"macOS packages are currently unsigned and non-notarized.",
|
||||||
|
))
|
||||||
|
.stdout(predicate::str::contains("Commands:").not())
|
||||||
|
.stdout(predicate::str::contains("ui").not())
|
||||||
|
.stdout(predicate::str::contains("run [OPTIONS]").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();
|
||||||
}
|
}
|
||||||
|
|||||||
26
sdk/scripts/check_docs_consistency.sh
Executable file
26
sdk/scripts/check_docs_consistency.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if rg -n \
|
||||||
|
--glob '*.md' \
|
||||||
|
--glob '*.yml' \
|
||||||
|
--glob '*.sh' \
|
||||||
|
--glob '*.rb' \
|
||||||
|
--glob 'PKGBUILD' \
|
||||||
|
--glob '.SRCINFO' \
|
||||||
|
-g '!CHANGELOG.md' \
|
||||||
|
'v0\.0\.1-rc\.1|v0\.0\.1-rc\.2|0\.0\.1-rc\.1|0\.0\.1-rc\.2|0\.0\.1rc1|0\.0\.1rc2' \
|
||||||
|
.github \
|
||||||
|
README.md \
|
||||||
|
MIGRATION.md \
|
||||||
|
RC_CHECKLIST.md \
|
||||||
|
packaging \
|
||||||
|
process \
|
||||||
|
sdk \
|
||||||
|
spec; then
|
||||||
|
echo "stale rc.1/rc.2 references remain outside CHANGELOG.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -12,4 +12,20 @@ if rg -n --hidden -g '!target/**' -g '!scripts/cleanroom_guard.sh' "$forbidden_p
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "cleanroom guard passed"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
||||||
VERSION="${1:-v0.0.1-rc.1}"
|
VERSION="${1:-v0.0.0-local}"
|
||||||
ARCH_LABEL="${2:-$(uname -m)}"
|
ARCH_LABEL="${2:-$(uname -m)}"
|
||||||
TARGET_TRIPLE="${3:-}"
|
TARGET_TRIPLE="${3:-}"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
||||||
VERSION="${1:-v0.0.1-rc.1}"
|
VERSION="${1:-v0.0.0-local}"
|
||||||
ARCH_LABEL="${2:-arm64}"
|
ARCH_LABEL="${2:-arm64}"
|
||||||
TARGET_TRIPLE="${3:-aarch64-apple-darwin}"
|
TARGET_TRIPLE="${3:-aarch64-apple-darwin}"
|
||||||
INSTALL_PREFIX="${4:-/opt/homebrew/bin}"
|
INSTALL_PREFIX="${4:-/opt/homebrew/bin}"
|
||||||
|
|||||||
@@ -1,50 +1,15 @@
|
|||||||
use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid};
|
use bitdo_proto::{
|
||||||
|
find_command, CommandId, DeviceSession, MockTransport, SafetyClass, SessionConfig, VidPid,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[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 transport = MockTransport::default();
|
||||||
|
push_diag_success_sequence_for_u2(&mut transport);
|
||||||
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);
|
|
||||||
|
|
||||||
let mut session = DeviceSession::new(
|
let mut session = DeviceSession::new(
|
||||||
transport,
|
transport,
|
||||||
VidPid::new(0x2dc8, 24585),
|
VidPid::new(0x2dc8, 0x6012),
|
||||||
SessionConfig {
|
SessionConfig {
|
||||||
experimental: true,
|
experimental: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -53,6 +18,177 @@ fn diag_probe_returns_command_checks() {
|
|||||||
.expect("session init");
|
.expect("session init");
|
||||||
|
|
||||||
let diag = session.diag_probe();
|
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::{
|
use bitdo_proto::{
|
||||||
find_command, BitdoError, CommandId, CommandRuntimePolicy, DeviceSession, DiagSeverity,
|
find_command, BitdoError, CommandId, CommandRuntimePolicy, DeviceSession, DiagSeverity,
|
||||||
EvidenceConfidence, MockTransport, SessionConfig, VidPid,
|
EvidenceConfidence, MockTransport, ResponseStatus, SessionConfig, VidPid,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -71,6 +71,9 @@ fn diag_probe_marks_inferred_reads_as_experimental() {
|
|||||||
.expect("inferred check present");
|
.expect("inferred check present");
|
||||||
assert!(inferred.is_experimental);
|
assert!(inferred.is_experimental);
|
||||||
assert_eq!(inferred.confidence, EvidenceConfidence::Inferred);
|
assert_eq!(inferred.confidence, EvidenceConfidence::Inferred);
|
||||||
|
assert!(inferred.attempts >= 1);
|
||||||
|
assert_eq!(inferred.response_status, ResponseStatus::Malformed);
|
||||||
|
assert!(inferred.bytes_written > 0);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
inferred.severity,
|
inferred.severity,
|
||||||
DiagSeverity::Ok | DiagSeverity::Warning | DiagSeverity::NeedsAttention
|
DiagSeverity::Ok | DiagSeverity::Warning | DiagSeverity::NeedsAttention
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# Alias Index
|
# Alias Index
|
||||||
|
|
||||||
OpenBitdo uses a strict no-duplicate PID model in runtime and canonical spec tables.
|
OpenBitdo keeps one canonical row per PID. Historical duplicate names are tracked here as aliases only.
|
||||||
These legacy names are preserved here as aliases only.
|
|
||||||
|
|
||||||
| Alias PID Name | Canonical PID Name | PID (hex) | Note |
|
| Alias PID Name | Canonical PID Name | PID (hex) | Note |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `PID_Pro2_OLD` | `PID_Pro2` | `0x6003` | Legacy constant alias in 8BitDo source; runtime/spec use only `PID_Pro2`. |
|
| `PID_Pro2_OLD` | `PID_Pro2` | `0x6003` | Historical constant alias; runtime and specs use `PID_Pro2`. |
|
||||||
| `PID_ASLGMouse` | `PID_Mouse` | `0x5205` | Alias constant maps to the same PID as `PID_Mouse`; runtime/spec use only `PID_Mouse`. |
|
| `PID_ASLGMouse` | `PID_Mouse` | `0x5205` | Historical alias for the same canonical mouse PID. |
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
# Device Name Catalog
|
# Device Name Catalog
|
||||||
|
|
||||||
This catalog is the sanitized naming source of truth for OpenBitdo runtime/docs.
|
This catalog is the canonical PID-to-name reference for OpenBitdo.
|
||||||
Canonical rows are unique by PID (no duplicates in this table).
|
Use it when you need a stable display name for runtime UI, docs, packaging copy, or evidence review.
|
||||||
|
|
||||||
|
## How To Use This Catalog
|
||||||
|
|
||||||
|
- Match devices by `pid_hex` first.
|
||||||
|
- Treat `display_name_en` as the preferred user-facing label.
|
||||||
|
- Keep aliases out of new canonical rows; add them to the `aliases` column and the alias index instead.
|
||||||
|
- When confidence is low or the source is `internal-fallback`, treat the name as provisional until better evidence is collected.
|
||||||
|
|
||||||
| canonical_pid_name | pid_hex | display_name_en | protocol_family | name_source | source_confidence | aliases |
|
| canonical_pid_name | pid_hex | display_name_en | protocol_family | name_source | source_confidence | aliases |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
@@ -64,5 +71,7 @@ Canonical rows are unique by PID (no duplicates in this table).
|
|||||||
| PID_ASLGJP | 0x205a | Riviera Keyboard | JpHandshake | vendor-language-map | high | |
|
| PID_ASLGJP | 0x205a | Riviera Keyboard | JpHandshake | vendor-language-map | high | |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Name-source references are indexed in `/Users/brooklyn/data/8bitdo/cleanroom/process/device_name_sources.md`.
|
|
||||||
- Alias names are documented in `/Users/brooklyn/data/8bitdo/cleanroom/spec/alias_index.md` and intentionally excluded from canonical PID rows.
|
- Canonical rows are unique by PID. Do not duplicate a PID to reflect a marketing alias.
|
||||||
|
- Name-source evidence is indexed in `/Users/brooklyn/data/8bitdo/cleanroom/process/device_name_sources.md`.
|
||||||
|
- Alias names live in `/Users/brooklyn/data/8bitdo/cleanroom/spec/alias_index.md` and stay out of the primary PID rows unless they become the canonical public name.
|
||||||
|
|||||||
@@ -1,98 +1,93 @@
|
|||||||
# 8BitDo Clean-Room Protocol Specification (Sanitized)
|
# OpenBitdo Protocol Overview
|
||||||
|
|
||||||
## Scope
|
This document summarizes the sanitized protocol model used by the clean-room runtime.
|
||||||
This document defines a sanitized command and transport contract for a clean-room Rust implementation.
|
|
||||||
It is intentionally independent from reverse-engineered source code details and uses stable requirement IDs.
|
|
||||||
|
|
||||||
## Wire Model
|
## Wire Model
|
||||||
- Transport: HID-like reports
|
|
||||||
- Primary report width: 64 bytes (`Standard64`, `DInput`, `JpHandshake` families)
|
- HID-like command transport
|
||||||
- Variable-length reports: allowed for boot/firmware phases
|
- primary 64-byte reports for `Standard64`, `DInput`, and `JpHandshake`
|
||||||
- Byte order: little-endian for multi-byte numeric fields
|
- variable-length reports only where firmware or boot phases require them
|
||||||
|
- little-endian multi-byte numbers
|
||||||
|
|
||||||
## Protocol Families
|
## Protocol Families
|
||||||
- `Standard64`: standard 64-byte command and response flow
|
|
||||||
- `JpHandshake`: alternate handshake and version probing workflow
|
- `Standard64`
|
||||||
- `DInput`: command family used for mode and runtime profile operations
|
- `JpHandshake`
|
||||||
- `DS4Boot`: reserved boot mode for DS4-style update path
|
- `DInput`
|
||||||
- `Unknown`: fallback for unknown devices
|
- `DS4Boot`
|
||||||
|
- `Unknown`
|
||||||
|
|
||||||
## Safety Classes
|
## Safety Classes
|
||||||
- `SafeRead`: read-only operations
|
|
||||||
- `SafeWrite`: runtime settings/profile writes
|
|
||||||
- `UnsafeBoot`: bootloader transitions with brick risk
|
|
||||||
- `UnsafeFirmware`: firmware transfer/commit operations with brick risk
|
|
||||||
|
|
||||||
## Response Validation Contract
|
- `SafeRead`: diagnostics and metadata reads
|
||||||
- Responses are validated per command against byte-pattern expectations from `command_matrix.csv`
|
- `SafeWrite`: profile, setting, or mapping writes
|
||||||
- Validation outcomes: `Ok`, `Invalid`, `Malformed`
|
- `UnsafeBoot`: bootloader transitions
|
||||||
- Retry policy applies on `Malformed` or timeout responses
|
- `UnsafeFirmware`: firmware transfer and commit operations
|
||||||
|
|
||||||
|
## Response Validation
|
||||||
|
|
||||||
|
- every command validates against the registry table
|
||||||
|
- outcomes are `Ok`, `Invalid`, or `Malformed`
|
||||||
|
- retry logic applies on timeout or malformed data according to session policy
|
||||||
|
|
||||||
## Operation Groups
|
## Operation Groups
|
||||||
- `Core`: generic identify/mode/profile/boot/fallback commands
|
|
||||||
- `JP108Dedicated`: 108-key dedicated-button mapping + feature/voice operations
|
|
||||||
- `Ultimate2Core`: Ultimate2 mode/slot/core-map operations
|
|
||||||
- `Firmware`: device-scoped firmware enter/chunk/commit/exit operations
|
|
||||||
- `CoreDiag`: decompiler-first detect/diagnostic command subset for candidate-readonly PIDs
|
|
||||||
- `ModeProfileRead`: decompiler-first read-only mode/profile snapshot group for candidate-readonly PIDs
|
|
||||||
- `FirmwarePreflight`: decompiler-first firmware readiness metadata reads (no transfer enablement)
|
|
||||||
|
|
||||||
## JP108 Dedicated Support
|
- `Core`
|
||||||
- Supported targets: `0x5209` (`PID_108JP`), `0x520a` (`PID_108JPUSB`)
|
- `JP108Dedicated`
|
||||||
- First milestone mapping scope: `A`, `B`, `K1`-`K8`
|
- `Ultimate2Core`
|
||||||
- Additional controls in this group:
|
- `Firmware`
|
||||||
- feature flags read/write
|
- `CoreDiag`
|
||||||
- voice setting read/write
|
- `ModeProfileRead`
|
||||||
- Full 111-key matrix remap is explicitly out of scope for this milestone.
|
- `FirmwarePreflight`
|
||||||
|
|
||||||
## Ultimate2 Core Support
|
## Support Model
|
||||||
- Supported targets: `0x6012` (`PID_Ultimate2`), `0x6013` (`PID_Ultimate2RR`)
|
|
||||||
- First milestone editable scope:
|
|
||||||
- current mode read/set
|
|
||||||
- current slot read
|
|
||||||
- slot config read/write
|
|
||||||
- core button map read/write
|
|
||||||
- Advanced subsystems (theme/sixaxis/deep macro editing) are intentionally hidden in this milestone.
|
|
||||||
|
|
||||||
## PID-Aware Command Gating
|
### Support Levels
|
||||||
- Command availability is gated by:
|
|
||||||
1. safety class and runtime unsafe acknowledgements
|
|
||||||
2. capability flags
|
|
||||||
3. explicit PID allowlist from `command_matrix.csv:applies_to`
|
|
||||||
- `applies_to="*"` means globally available within existing safety/capability constraints.
|
|
||||||
|
|
||||||
## Device Support Levels
|
- `full`
|
||||||
- `full`: command execution permitted for safe and unsafe operations (with user gates)
|
- `detect-only`
|
||||||
- `detect-only`: identification allowed; unsupported operations return `UnsupportedForPid`
|
|
||||||
|
|
||||||
## Support Tiers
|
### Support Tiers
|
||||||
- `full`: read/write/unsafe operations available according to existing safety gates.
|
|
||||||
- `candidate-readonly`: detect/diag safe reads are allowed per PID allowlist; safe writes and unsafe flows are blocked.
|
|
||||||
- `detect-only`: identify-only posture for unsupported or unknown PIDs.
|
|
||||||
|
|
||||||
## Candidate Read-Only Wave Policy
|
- `full`: normal read, write, and gated unsafe paths
|
||||||
- Wave-1 and Wave-2 expansion PIDs are classified as `candidate-readonly`.
|
- `candidate-readonly`: safe-read diagnostics only
|
||||||
- Command policy for this tier:
|
- `detect-only`: identify-only posture
|
||||||
- allow: detect/diag safe-read subset.
|
|
||||||
- allow: read-only mode/profile snapshot reads when family-appropriate.
|
|
||||||
- allow: firmware metadata/preflight reads only.
|
|
||||||
- deny: all safe-write operations.
|
|
||||||
- deny: all unsafe boot/firmware operations.
|
|
||||||
- Promotion from `candidate-readonly` to `full` requires 3-signal evidence:
|
|
||||||
1. static dossier coverage
|
|
||||||
2. runtime trace evidence
|
|
||||||
3. hardware read/write/readback confirmation
|
|
||||||
|
|
||||||
## Dossier Linkage
|
## Candidate Read-Only Policy
|
||||||
- Per-PID operation evidence is tracked in `spec/dossiers/**`.
|
|
||||||
- `command_matrix.csv:dossier_id` links command rows to sanitized dossier artifacts.
|
|
||||||
- `evidence_index.csv` maps PID to class-family anchors and operation groups.
|
|
||||||
|
|
||||||
## Required Runtime Gating
|
Read-only candidates may:
|
||||||
Unsafe commands execute only when both conditions are true:
|
|
||||||
1. `--unsafe`
|
|
||||||
2. `--i-understand-brick-risk`
|
|
||||||
|
|
||||||
## Clean-Room Requirements Linkage
|
- identify themselves
|
||||||
Implementation and tests must trace to IDs in `requirements.yaml`.
|
- run allowed safe-read diagnostics
|
||||||
All public APIs and behavior are governed by `REQ-PROT-*`, `REQ-PID-*`, `REQ-SAFE-*`, and `REQ-TEST-*` IDs.
|
- perform family-appropriate read-only metadata checks
|
||||||
|
|
||||||
|
Read-only candidates may not:
|
||||||
|
|
||||||
|
- write mappings or profiles
|
||||||
|
- enter unsafe boot paths
|
||||||
|
- transfer firmware
|
||||||
|
|
||||||
|
Promotion to full support requires:
|
||||||
|
|
||||||
|
1. static evidence
|
||||||
|
2. runtime evidence
|
||||||
|
3. hardware evidence
|
||||||
|
|
||||||
|
## Feature Scopes
|
||||||
|
|
||||||
|
### JP108
|
||||||
|
|
||||||
|
- supported targets: `0x5209`, `0x520a`
|
||||||
|
- current mapping scope: `A`, `B`, `K1`-`K8`
|
||||||
|
|
||||||
|
### Ultimate 2
|
||||||
|
|
||||||
|
- supported targets: `0x6012`, `0x6013`
|
||||||
|
- current scope: mode, slot, slot config, core button map, and required analog handling
|
||||||
|
|
||||||
|
## Runtime Safety Rule
|
||||||
|
|
||||||
|
Unsafe commands are only allowed when the runtime has both:
|
||||||
|
|
||||||
|
1. unsafe mode enabled
|
||||||
|
2. explicit brick-risk acknowledgment
|
||||||
|
|||||||
Reference in New Issue
Block a user