Compare commits
15 Commits
dcb1e6596e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac68e09ab | |||
| 1dbbf191e6 | |||
| 32f258a492 | |||
| c581b4d92c | |||
| aafa9928ac | |||
| 8b24084f97 | |||
| dd2afa34ef | |||
| fb85565854 | |||
| 7a33e71410 | |||
| e704f27a96 | |||
| 08405c879b | |||
| 76c0fbe237 | |||
| d3a2fe6613 | |||
| a673aa14b7 | |||
| 575ccaae42 |
49
.beads/.gitignore
vendored
Normal file
49
.beads/.gitignore
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
# Dolt database (managed by Dolt, not git)
|
||||
dolt/
|
||||
dolt-access.lock
|
||||
|
||||
# Runtime files
|
||||
bd.sock
|
||||
bd.sock.startlock
|
||||
sync-state.json
|
||||
last-touched
|
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||
.local_version
|
||||
|
||||
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||
# Must not be committed as paths would be wrong in other clones
|
||||
redirect
|
||||
|
||||
# Sync state (local-only, per-machine)
|
||||
# These files are machine-specific and should not be shared across clones
|
||||
.sync.lock
|
||||
export-state/
|
||||
|
||||
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
|
||||
ephemeral.sqlite3
|
||||
ephemeral.sqlite3-journal
|
||||
ephemeral.sqlite3-wal
|
||||
ephemeral.sqlite3-shm
|
||||
|
||||
# Dolt server management (auto-started by bd)
|
||||
dolt-server.pid
|
||||
dolt-server.log
|
||||
dolt-server.lock
|
||||
|
||||
# Legacy files (from pre-Dolt versions)
|
||||
*.db
|
||||
*.db?*
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
db.sqlite
|
||||
bd.db
|
||||
daemon.lock
|
||||
daemon.log
|
||||
daemon-*.log.gz
|
||||
daemon.pid
|
||||
# NOTE: Do NOT add negation patterns here.
|
||||
# They would override fork protection in .git/info/exclude.
|
||||
# Config files (metadata.json, config.yaml) are tracked by git by default
|
||||
# since no pattern above ignores them.
|
||||
81
.beads/README.md
Normal file
81
.beads/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Beads - AI-Native Issue Tracking
|
||||
|
||||
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
|
||||
|
||||
## What is Beads?
|
||||
|
||||
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
|
||||
|
||||
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Create new issues
|
||||
bd create "Add user authentication"
|
||||
|
||||
# View all issues
|
||||
bd list
|
||||
|
||||
# View issue details
|
||||
bd show <issue-id>
|
||||
|
||||
# Update issue status
|
||||
bd update <issue-id> --claim
|
||||
bd update <issue-id> --status done
|
||||
|
||||
# Sync with Dolt remote
|
||||
bd dolt push
|
||||
```
|
||||
|
||||
### Working with Issues
|
||||
|
||||
Issues in Beads are:
|
||||
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
|
||||
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
|
||||
- **Branch-aware**: Issues can follow your branch workflow
|
||||
- **Always in sync**: Auto-syncs with your commits
|
||||
|
||||
## Why Beads?
|
||||
|
||||
✨ **AI-Native Design**
|
||||
- Built specifically for AI-assisted development workflows
|
||||
- CLI-first interface works seamlessly with AI coding agents
|
||||
- No context switching to web UIs
|
||||
|
||||
🚀 **Developer Focused**
|
||||
- Issues live in your repo, right next to your code
|
||||
- Works offline, syncs when you push
|
||||
- Fast, lightweight, and stays out of your way
|
||||
|
||||
🔧 **Git Integration**
|
||||
- Automatic sync with git commits
|
||||
- Branch-aware issue tracking
|
||||
- Intelligent JSONL merge resolution
|
||||
|
||||
## Get Started with Beads
|
||||
|
||||
Try Beads in your own projects:
|
||||
|
||||
```bash
|
||||
# Install Beads
|
||||
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
|
||||
|
||||
# Initialize in your repo
|
||||
bd init
|
||||
|
||||
# Create your first issue
|
||||
bd create "Try out Beads"
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
|
||||
- **Quick Start Guide**: Run `bd quickstart`
|
||||
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
|
||||
|
||||
---
|
||||
|
||||
*Beads: Issue tracking that moves at the speed of thought* ⚡
|
||||
13
.beads/backup/backup_state.json
Normal file
13
.beads/backup/backup_state.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"last_dolt_commit": "mf3is3p9ve79on2q1achdjp8v50rq6s3",
|
||||
"last_event_id": 0,
|
||||
"timestamp": "2026-03-04T17:07:18.317614374Z",
|
||||
"counts": {
|
||||
"issues": 10,
|
||||
"events": 24,
|
||||
"comments": 0,
|
||||
"dependencies": 21,
|
||||
"labels": 0,
|
||||
"config": 11
|
||||
}
|
||||
}
|
||||
0
.beads/backup/comments.jsonl
Normal file
0
.beads/backup/comments.jsonl
Normal file
11
.beads/backup/config.jsonl
Normal file
11
.beads/backup/config.jsonl
Normal file
@ -0,0 +1,11 @@
|
||||
{"key":"auto_compact_enabled","value":"false"}
|
||||
{"key":"compact_batch_size","value":"50"}
|
||||
{"key":"compact_parallel_workers","value":"5"}
|
||||
{"key":"compact_tier1_days","value":"30"}
|
||||
{"key":"compact_tier1_dep_levels","value":"2"}
|
||||
{"key":"compact_tier2_commits","value":"100"}
|
||||
{"key":"compact_tier2_days","value":"90"}
|
||||
{"key":"compact_tier2_dep_levels","value":"5"}
|
||||
{"key":"compaction_enabled","value":"false"}
|
||||
{"key":"issue_prefix","value":"abawo_bt_app"}
|
||||
{"key":"schema_version","value":"6"}
|
||||
21
.beads/backup/dependencies.jsonl
Normal file
21
.beads/backup/dependencies.jsonl
Normal file
@ -0,0 +1,21 @@
|
||||
{"created_at":"2026-03-03T16:38:33Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.1","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.2","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.2","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.3","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.4","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.4","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.5","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.5","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.6","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.3","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.5","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.9","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:17Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.7","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.2","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.8","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:23Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.8","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.8","type":"blocks"}
|
||||
{"created_at":"2026-03-03T16:39:28Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.9","type":"parent-child"}
|
||||
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.9","type":"blocks"}
|
||||
24
.beads/backup/events.jsonl
Normal file
24
.beads/backup/events.jsonl
Normal file
@ -0,0 +1,24 @@
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:37:50Z","event_type":"created","id":1,"issue_id":"abawo_bt_app-20q","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:38:33Z","event_type":"created","id":2,"issue_id":"abawo_bt_app-20q.1","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":3,"issue_id":"abawo_bt_app-20q.2","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":5,"issue_id":"abawo_bt_app-20q.3","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":7,"issue_id":"abawo_bt_app-20q.4","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":9,"issue_id":"abawo_bt_app-20q.5","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":10,"issue_id":"abawo_bt_app-20q.6","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:17Z","event_type":"created","id":11,"issue_id":"abawo_bt_app-20q.7","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:23Z","event_type":"created","id":12,"issue_id":"abawo_bt_app-20q.8","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:28Z","event_type":"created","id":13,"issue_id":"abawo_bt_app-20q.9","new_value":"","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:43:26Z","event_type":"claimed","id":14,"issue_id":"abawo_bt_app-20q.1","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.1\",\"title\":\"Add DFU protocol constants and domain models\",\"description\":\"Add protocol surface in app code for DFU support.\\n\\nWork:\\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\\n- Add flags constants and typed update-state/progress models used by service/UI\\n- Remove future magic numbers by centralizing constants\",\"acceptance_criteria\":\"- All protocol constants match spec exactly\\n- No duplicated literal DFU UUID/opcode values across services/UI\\n- Domain models compile and are ready for transfer engine integration\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:38:34Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:38:34Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:19Z","event_type":"closed","id":15,"issue_id":"abawo_bt_app-20q.1","new_value":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:29Z","event_type":"claimed","id":16,"issue_id":"abawo_bt_app-20q.2","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.2\",\"title\":\"Implement DFU packet codec and CRC32 utilities with tests\",\"description\":\"Implement pure protocol helpers.\\n\\nWork:\\n- Build START payload (11 bytes, LE fields)\\n- Build FINISH/ABORT payloads\\n- Build/segment DATA frames (seq + 63-byte payload)\\n- Implement ACK/sequence helpers including wrapping behavior\\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)\",\"acceptance_criteria\":\"- CRC test vector passes: \\\"123456789\\\" =\\u003e 0xCBF43926\\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\\n- Frame segmentation handles final partial payload correctly\\n- Seq wrap and ack+1 rewind helpers covered by tests\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:48:57Z","event_type":"closed","id":17,"issue_id":"abawo_bt_app-20q.2","new_value":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:49:06Z","event_type":"claimed","id":18,"issue_id":"abawo_bt_app-20q.8","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.8\",\"title\":\"Add BLE DFU preflight checks (MTU and connection readiness)\",\"description\":\"Add runtime guards required for protocol correctness.\\n\\nWork:\\n- Ensure active connection to target button before DFU start\\n- Request elevated MTU (e.g. 128/247) before upload\\n- Validate negotiated MTU supports 64-byte data writes (ATT payload requirement)\\n- Fail early with actionable message when transport preconditions are not met\",\"acceptance_criteria\":\"- Upload start is blocked when MTU/connection preconditions fail\\n- Error messages explain what failed and next step\\n- Preflight result is exposed for transfer start path\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:24Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:24Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:55:10Z","event_type":"closed","id":19,"issue_id":"abawo_bt_app-20q.8","new_value":"Implemented DFU preflight checks for connection state and negotiated MTU with typed results and tests","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:55:19Z","event_type":"claimed","id":20,"issue_id":"abawo_bt_app-20q.7","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.7\",\"title\":\"Implement BLE DFU transfer engine with cumulative ACK retransmit\",\"description\":\"Build the runtime transfer engine used by UI.\\n\\nWork:\\n- Subscribe to dfu_ack indications before START\\n- Send START and require initial ACK 0xFF\\n- Stream dfu_data using write without response in windows (configurable, default 8)\\n- Track cumulative ACK; on stall/timeout rewind to ack+1 (wrapping aware)\\n- Handle invalid/no-progress scenarios with bounded retries\\n- Send FINISH after full acked upload\\n- Support ABORT for cancellation and terminal error cleanup\\n- Emit state/progress stream for UI\",\"acceptance_criteria\":\"- Happy path reaches done with full ACKed transfer\\n- Loss/stall path retransmits and recovers correctly\\n- Cancel triggers ABORT and returns to idle cleanly\\n- Engine surfaces explicit error reasons for UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"feature\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:18Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:18Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:00:44Z","event_type":"closed","id":21,"issue_id":"abawo_bt_app-20q.7","new_value":"Implemented firmware transfer engine with preflight, cumulative ACK handling, retries, cancel ABORT, and unit tests","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:01:01Z","event_type":"claimed","id":22,"issue_id":"abawo_bt_app-20q.4","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.4\",\"title\":\"Add firmware file selection and binary validation flow\",\"description\":\"Implement local firmware artifact input for v1.\\n\\nWork:\\n- Integrate file picker for local firmware .bin\\n- Read bytes safely and validate non-empty payload\\n- Guard against malformed selections and unsupported files\\n- Compute total_len and crc32 from selected bytes\\n- Generate per-session session_id and set flags=0x00 for v1\",\"acceptance_criteria\":\"- User can select .bin and app obtains byte payload\\n- Validation errors are explicit and user-facing\\n- Metadata (size/crc/session) is available to transfer engine\",\"status\":\"open\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:04:51Z","event_type":"closed","id":23,"issue_id":"abawo_bt_app-20q.4","new_value":"Completed","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:07:21Z","event_type":"claimed","id":24,"issue_id":"abawo_bt_app-20q.9","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.9\",\"title\":\"Handle post-FINISH disconnect/reboot and reconnect verification\",\"description\":\"Implement robust completion handling around expected device reset.\\n\\nWork:\\n- Treat disconnect after successful FINISH as expected behavior\\n- Reconnect with update-specific timeout strategy\\n- Verify device is reachable/readable after reconnect\\n- Surface success only after reconnect verification path\\n- Document limitation: no strict firmware version compare until firmware exposes version characteristic\",\"acceptance_criteria\":\"- Expected reset does not appear as generic failure\\n- Reconnect path is attempted and result is surfaced\\n- Completion criteria are consistent with v1 definition\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:28Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:28Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:12:02Z","event_type":"closed","id":25,"issue_id":"abawo_bt_app-20q.9","new_value":"Added post-FINISH reset disconnect, reconnect, and reachability verification before marking update complete","old_value":""}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:12:17Z","event_type":"claimed","id":26,"issue_id":"abawo_bt_app-20q.3","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.3\",\"title\":\"Integrate firmware update UI into device details page\",\"description\":\"Add user-facing update controls and status presentation.\\n\\nWork:\\n- Add update card with Select Firmware, Start Update, Cancel\\n- Show phase text, progress %, bytes sent/acked, and retry status\\n- Disable conflicting actions (gear writes / connect button-to-bike) during DFU\\n- Show explicit reboot expectation after FINISH\\n- Persist/clear transient state correctly on page lifecycle changes\",\"acceptance_criteria\":\"- UI can run full update flow start-to-finish\\n- Progress/state transitions are visible and consistent\\n- Conflicting controls are disabled during active transfer\\n- Failures and cancellations are clearly shown\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"feature\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
|
||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-04T18:07:17Z","event_type":"closed","id":27,"issue_id":"abawo_bt_app-20q.3","new_value":"Integrated firmware file selection, update controls, progress display, and DFU state handling into device details page","old_value":""}
|
||||
10
.beads/backup/issues.jsonl
Normal file
10
.beads/backup/issues.jsonl
Normal file
@ -0,0 +1,10 @@
|
||||
{"acceptance_criteria":"- User can select a firmware .bin and complete upload end-to-end\n- Upload follows protocol and handles packet loss via retransmit\n- App shows clear progress and failure states\n- Device reconnects after reboot and is reachable","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"c3bd8fa9b5b9d51b04cde5e11c2d8cdedaca29e049a64e8521172a69093b4ba3","created_at":"2026-03-03T15:37:50Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement the firmware update flow defined in universal-shifters/update-process.md for the Flutter app.\n\nScope:\n- Manual local .bin selection and upload over BLE GATT\n- START/DATA/FINISH/ABORT protocol support\n- Cumulative ACK handling with retransmit\n- Expected reboot/disconnect handling and reconnect check\n\nOut of scope for v1:\n- Hosted firmware distribution/backend\n- Cryptographic signature verification in app\n- Encrypted payload transport mode","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q","is_template":0,"issue_type":"epic","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Implement Universal Shifters BLE DFU v1 in app (manual .bin upload)","updated_at":"2026-03-03T15:37:50Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- All protocol constants match spec exactly\n- No duplicated literal DFU UUID/opcode values across services/UI\n- Domain models compile and are ready for transfer engine integration","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","closed_at":"2026-03-03T15:45:20Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"d471157a1af6199f0ac0a1565b05b28d1a477e66d9166a3865edb42ea1e8c2ae","created_at":"2026-03-03T15:38:34Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add protocol surface in app code for DFU support.\n\nWork:\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\n- Add flags constants and typed update-state/progress models used by service/UI\n- Remove future magic numbers by centralizing constants","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.1","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add DFU protocol constants and domain models","updated_at":"2026-03-03T15:45:20Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- CRC test vector passes: \"123456789\" =\u003e 0xCBF43926\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\n- Frame segmentation handles final partial payload correctly\n- Seq wrap and ack+1 rewind helpers covered by tests","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","closed_at":"2026-03-03T15:48:57Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"9ba609e63c9e2f8a4e95de6795dace11c610aa9c13908b5dab7aa6cb83ab58d4","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement pure protocol helpers.\n\nWork:\n- Build START payload (11 bytes, LE fields)\n- Build FINISH/ABORT payloads\n- Build/segment DATA frames (seq + 63-byte payload)\n- Implement ACK/sequence helpers including wrapping behavior\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.2","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Implement DFU packet codec and CRC32 utilities with tests","updated_at":"2026-03-03T15:48:57Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- UI can run full update flow start-to-finish\n- Progress/state transitions are visible and consistent\n- Conflicting controls are disabled during active transfer\n- Failures and cancellations are clearly shown","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Integrated firmware file selection, update controls, progress display, and DFU state handling into device details page","closed_at":"2026-03-04T17:07:18Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"e8d2f905536263a05de5e7e86bf8f02fa0491d7129e30f36f2c4dd09acab1882","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add user-facing update controls and status presentation.\n\nWork:\n- Add update card with Select Firmware, Start Update, Cancel\n- Show phase text, progress %, bytes sent/acked, and retry status\n- Disable conflicting actions (gear writes / connect button-to-bike) during DFU\n- Show explicit reboot expectation after FINISH\n- Persist/clear transient state correctly on page lifecycle changes","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.3","is_template":0,"issue_type":"feature","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Integrate firmware update UI into device details page","updated_at":"2026-03-04T17:07:18Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- User can select .bin and app obtains byte payload\n- Validation errors are explicit and user-facing\n- Metadata (size/crc/session) is available to transfer engine","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Completed","closed_at":"2026-03-03T16:04:51Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"be8d8646593e4fc6c269383efb41808c40ffdfb3103e58a90acc0142e32f4711","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement local firmware artifact input for v1.\n\nWork:\n- Integrate file picker for local firmware .bin\n- Read bytes safely and validate non-empty payload\n- Guard against malformed selections and unsupported files\n- Compute total_len and crc32 from selected bytes\n- Generate per-session session_id and set flags=0x00 for v1","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.4","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add firmware file selection and binary validation flow","updated_at":"2026-03-03T16:04:51Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- Tests cover success and critical failure/retry paths\n- Wrap-around and ack rewind behavior is validated\n- Regressions in sequencing/CRC are caught automatically","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"f784095261bfc41eed5544a567f667bb2ba4816433f1d5d6ae21ea4616c7109a","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add targeted tests for protocol and engine behavior.\n\nWork:\n- Unit tests for codec + CRC + sequence helpers\n- Engine tests with mocked BLE ack stream for:\n - happy path\n - dropped frame / stalled ACK and rewind\n - timeout and bounded retry fail\n - cancel/abort cleanup\n- Ensure deterministic tests for wrap-around sequence scenarios","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.5","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Add DFU test suite for happy path, loss, stalls, and cancel","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- Team can execute update and triage failures from docs\n- v1 limitations are explicit and not ambiguous\n- QA checklist is actionable and complete","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"3a5dd3fd4d901ae53a80b3c7b488b41db5e09f7cc50da0e6c185c075b8ad7e51","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Document how to use and support the new updater.\n\nWork:\n- Add app-side DFU flow docs (select/start/progress/reboot/reconnect)\n- Add troubleshooting matrix for common failures (MTU, stalled ACK, reconnect timeout, CRC mismatch)\n- Record explicit v1 limitations and future security/version-verification roadmap\n- Add manual QA checklist for release validation","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.6","is_template":0,"issue_type":"chore","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Document DFU v1 operator flow, troubleshooting, and constraints","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- Happy path reaches done with full ACKed transfer\n- Loss/stall path retransmits and recovers correctly\n- Cancel triggers ABORT and returns to idle cleanly\n- Engine surfaces explicit error reasons for UI","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented firmware transfer engine with preflight, cumulative ACK handling, retries, cancel ABORT, and unit tests","closed_at":"2026-03-03T16:00:45Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"042d39f373b05c758d2d2724c757a7e790de522c2def68f8f12e9f2fbbb70dc6","created_at":"2026-03-03T15:39:18Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Build the runtime transfer engine used by UI.\n\nWork:\n- Subscribe to dfu_ack indications before START\n- Send START and require initial ACK 0xFF\n- Stream dfu_data using write without response in windows (configurable, default 8)\n- Track cumulative ACK; on stall/timeout rewind to ack+1 (wrapping aware)\n- Handle invalid/no-progress scenarios with bounded retries\n- Send FINISH after full acked upload\n- Support ABORT for cancellation and terminal error cleanup\n- Emit state/progress stream for UI","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.7","is_template":0,"issue_type":"feature","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Implement BLE DFU transfer engine with cumulative ACK retransmit","updated_at":"2026-03-03T16:00:45Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- Upload start is blocked when MTU/connection preconditions fail\n- Error messages explain what failed and next step\n- Preflight result is exposed for transfer start path","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented DFU preflight checks for connection state and negotiated MTU with typed results and tests","closed_at":"2026-03-03T15:55:11Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"558fa59b42c42b122eefc43d3483d1a61bdd995464dbc36d166f763f42366e87","created_at":"2026-03-03T15:39:24Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add runtime guards required for protocol correctness.\n\nWork:\n- Ensure active connection to target button before DFU start\n- Request elevated MTU (e.g. 128/247) before upload\n- Validate negotiated MTU supports 64-byte data writes (ATT payload requirement)\n- Fail early with actionable message when transport preconditions are not met","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.8","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add BLE DFU preflight checks (MTU and connection readiness)","updated_at":"2026-03-03T15:55:11Z","waiters":"","wisp_type":"","work_type":""}
|
||||
{"acceptance_criteria":"- Expected reset does not appear as generic failure\n- Reconnect path is attempted and result is surfaced\n- Completion criteria are consistent with v1 definition","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Added post-FINISH reset disconnect, reconnect, and reachability verification before marking update complete","closed_at":"2026-03-03T16:12:02Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"024e8aa245b93098013fbd66102a621c9ecd58a1dda9fc276c74cd278bdd3512","created_at":"2026-03-03T15:39:28Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement robust completion handling around expected device reset.\n\nWork:\n- Treat disconnect after successful FINISH as expected behavior\n- Reconnect with update-specific timeout strategy\n- Verify device is reachable/readable after reconnect\n- Surface success only after reconnect verification path\n- Document limitation: no strict firmware version compare until firmware exposes version characteristic","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.9","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Handle post-FINISH disconnect/reboot and reconnect verification","updated_at":"2026-03-03T16:12:02Z","waiters":"","wisp_type":"","work_type":""}
|
||||
0
.beads/backup/labels.jsonl
Normal file
0
.beads/backup/labels.jsonl
Normal file
55
.beads/config.yaml
Normal file
55
.beads/config.yaml
Normal file
@ -0,0 +1,55 @@
|
||||
# Beads Configuration File
|
||||
# This file configures default behavior for all bd commands in this repository
|
||||
# All settings can also be set via environment variables (BD_* prefix)
|
||||
# or overridden with command-line flags
|
||||
|
||||
# Issue prefix for this repository (used by bd init)
|
||||
# If not set, bd init will auto-detect from directory name
|
||||
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
||||
# issue-prefix: ""
|
||||
|
||||
# Use no-db mode: load from JSONL, write back after each command
|
||||
# When true, bd will use .beads/issues.jsonl as the source of truth
|
||||
# instead of the Dolt database
|
||||
# no-db: false
|
||||
|
||||
# Enable JSON output by default
|
||||
# json: false
|
||||
|
||||
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
|
||||
# 0 = hide titles, N > 0 = truncate to N characters
|
||||
# output:
|
||||
# title-length: 255
|
||||
|
||||
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
|
||||
# actor: ""
|
||||
|
||||
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
|
||||
# When enabled, new events are appended incrementally using a high-water mark.
|
||||
# Use 'bd export --events' to trigger manually regardless of this setting.
|
||||
# events-export: false
|
||||
|
||||
# Multi-repo configuration (experimental - bd-307)
|
||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||
# repos:
|
||||
# primary: "." # Primary repo (where this database lives)
|
||||
# additional: # Additional repos to hydrate from (read-only)
|
||||
# - ~/beads-planning # Personal planning repo
|
||||
# - ~/work-planning # Work planning repo
|
||||
|
||||
# JSONL backup (periodic export for off-machine recovery)
|
||||
# Auto-enabled when a git remote exists. Override explicitly:
|
||||
# backup:
|
||||
# enabled: false # Disable auto-backup entirely
|
||||
# interval: 15m # Minimum time between auto-exports
|
||||
# git-push: false # Disable git push (export locally only)
|
||||
# git-repo: "" # Separate git repo for backups (default: project repo)
|
||||
|
||||
# Integration settings (access with 'bd config get/set')
|
||||
# These are stored in the database, not in this file:
|
||||
# - jira.url
|
||||
# - jira.project
|
||||
# - linear.url
|
||||
# - linear.api-key
|
||||
# - github.org
|
||||
# - github.repo
|
||||
1
.beads/dolt-monitor.pid
Normal file
1
.beads/dolt-monitor.pid
Normal file
@ -0,0 +1 @@
|
||||
48179
|
||||
1
.beads/dolt-server.activity
Normal file
1
.beads/dolt-server.activity
Normal file
@ -0,0 +1 @@
|
||||
1772550918
|
||||
1
.beads/dolt-server.port
Normal file
1
.beads/dolt-server.port
Normal file
@ -0,0 +1 @@
|
||||
13365
|
||||
9
.beads/hooks/post-checkout
Executable file
9
.beads/hooks/post-checkout
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
bd hooks run post-checkout "$@"
|
||||
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION ---
|
||||
9
.beads/hooks/post-merge
Executable file
9
.beads/hooks/post-merge
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
bd hooks run post-merge "$@"
|
||||
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION ---
|
||||
9
.beads/hooks/pre-commit
Executable file
9
.beads/hooks/pre-commit
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
bd hooks run pre-commit "$@"
|
||||
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION ---
|
||||
9
.beads/hooks/pre-push
Executable file
9
.beads/hooks/pre-push
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
bd hooks run pre-push "$@"
|
||||
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION ---
|
||||
9
.beads/hooks/prepare-commit-msg
Executable file
9
.beads/hooks/prepare-commit-msg
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
|
||||
# This section is managed by beads. Do not remove these markers.
|
||||
if command -v bd >/dev/null 2>&1; then
|
||||
export BD_GIT_HOOK=1
|
||||
bd hooks run prepare-commit-msg "$@"
|
||||
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||
fi
|
||||
# --- END BEADS INTEGRATION ---
|
||||
0
.beads/interactions.jsonl
Normal file
0
.beads/interactions.jsonl
Normal file
6
.beads/metadata.json
Normal file
6
.beads/metadata.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"database": "dolt",
|
||||
"backend": "dolt",
|
||||
"dolt_mode": "server",
|
||||
"dolt_database": "abawo_bt_app"
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -44,3 +44,7 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
.aider*
|
||||
|
||||
# Dolt database files (added by bd init)
|
||||
.dolt/
|
||||
*.db
|
||||
|
||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Agent Instructions
|
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --claim # Claim work atomically
|
||||
bd close <id> # Complete work
|
||||
bd sync # Sync with git
|
||||
```
|
||||
|
||||
## Non-Interactive Shell Commands
|
||||
|
||||
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
|
||||
|
||||
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
|
||||
|
||||
**Use these forms instead:**
|
||||
```bash
|
||||
# Force overwrite without prompting
|
||||
cp -f source dest # NOT: cp source dest
|
||||
mv -f source dest # NOT: mv source dest
|
||||
rm -f file # NOT: rm file
|
||||
|
||||
# For recursive operations
|
||||
rm -rf directory # NOT: rm -r directory
|
||||
cp -rf source dest # NOT: cp -r source dest
|
||||
```
|
||||
|
||||
**Other commands that may prompt:**
|
||||
- `scp` - use `-o BatchMode=yes` for non-interactive
|
||||
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
|
||||
- `apt-get` - use `-y` flag
|
||||
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION -->
|
||||
## Issue Tracking with bd (beads)
|
||||
|
||||
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
|
||||
|
||||
### Why bd?
|
||||
|
||||
- Dependency-aware: Track blockers and relationships between issues
|
||||
- Git-friendly: Auto-syncs to JSONL for version control
|
||||
- Agent-optimized: JSON output, ready work detection, discovered-from links
|
||||
- Prevents duplicate tracking systems and confusion
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Check for ready work:**
|
||||
|
||||
```bash
|
||||
bd ready --json
|
||||
```
|
||||
|
||||
**Create new issues:**
|
||||
|
||||
```bash
|
||||
bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json
|
||||
bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json
|
||||
```
|
||||
|
||||
**Claim and update:**
|
||||
|
||||
```bash
|
||||
bd update <id> --claim --json
|
||||
bd update bd-42 --priority 1 --json
|
||||
```
|
||||
|
||||
**Complete work:**
|
||||
|
||||
```bash
|
||||
bd close bd-42 --reason "Completed" --json
|
||||
```
|
||||
|
||||
### Issue Types
|
||||
|
||||
- `bug` - Something broken
|
||||
- `feature` - New functionality
|
||||
- `task` - Work item (tests, docs, refactoring)
|
||||
- `epic` - Large feature with subtasks
|
||||
- `chore` - Maintenance (dependencies, tooling)
|
||||
|
||||
### Priorities
|
||||
|
||||
- `0` - Critical (security, data loss, broken builds)
|
||||
- `1` - High (major features, important bugs)
|
||||
- `2` - Medium (default, nice-to-have)
|
||||
- `3` - Low (polish, optimization)
|
||||
- `4` - Backlog (future ideas)
|
||||
|
||||
### Workflow for AI Agents
|
||||
|
||||
1. **Check ready work**: `bd ready` shows unblocked issues
|
||||
2. **Claim your task atomically**: `bd update <id> --claim`
|
||||
3. **Work on it**: Implement, test, document
|
||||
4. **Discover new work?** Create linked issue:
|
||||
- `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:<parent-id>`
|
||||
5. **Complete**: `bd close <id> --reason "Done"`
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
bd automatically syncs with git:
|
||||
|
||||
- Exports to `.beads/issues.jsonl` after changes (5s debounce)
|
||||
- Imports from JSONL when newer (e.g., after `git pull`)
|
||||
- No manual export/import needed!
|
||||
|
||||
### Important Rules
|
||||
|
||||
- ✅ Use bd for ALL task tracking
|
||||
- ✅ Always use `--json` flag for programmatic use
|
||||
- ✅ Link discovered work with `discovered-from` dependencies
|
||||
- ✅ Check `bd ready` before asking "what should I work on?"
|
||||
- ❌ Do NOT create markdown TODO lists
|
||||
- ❌ Do NOT use external issue trackers
|
||||
- ❌ Do NOT duplicate tracking systems
|
||||
|
||||
For more details, see README.md and docs/QUICKSTART.md.
|
||||
|
||||
<!-- END BEADS INTEGRATION -->
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd sync
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
@ -299,11 +299,20 @@ class BluetoothController {
|
||||
|
||||
Future<Result<void>> requestMtu(String deviceId,
|
||||
{int mtu = defaultMtu}) async {
|
||||
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||
if (result.isErr()) {
|
||||
return bail(result.unwrapErr());
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
Future<Result<int>> requestMtuAndGetValue(String deviceId,
|
||||
{int mtu = defaultMtu}) async {
|
||||
try {
|
||||
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
||||
log.info(
|
||||
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
||||
return Ok(null);
|
||||
return Ok(negotiatedMtu);
|
||||
} catch (e) {
|
||||
return bail('Error requesting MTU $mtu for $deviceId: $e');
|
||||
}
|
||||
|
||||
70
lib/model/firmware_file_selection.dart
Normal file
70
lib/model/firmware_file_selection.dart
Normal file
@ -0,0 +1,70 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class DfuV1FirmwareMetadata {
|
||||
const DfuV1FirmwareMetadata({
|
||||
required this.totalLength,
|
||||
required this.crc32,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int crc32;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuV1PreparedFirmware {
|
||||
const DfuV1PreparedFirmware({
|
||||
required this.fileName,
|
||||
required this.fileBytes,
|
||||
required this.metadata,
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final String? filePath;
|
||||
final Uint8List fileBytes;
|
||||
final DfuV1FirmwareMetadata metadata;
|
||||
}
|
||||
|
||||
enum FirmwareSelectionFailureReason {
|
||||
canceled,
|
||||
malformedSelection,
|
||||
unsupportedExtension,
|
||||
emptyFile,
|
||||
readFailed,
|
||||
}
|
||||
|
||||
class FirmwareSelectionFailure {
|
||||
const FirmwareSelectionFailure({
|
||||
required this.reason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final FirmwareSelectionFailureReason reason;
|
||||
final String message;
|
||||
}
|
||||
|
||||
class FirmwareFileSelectionResult {
|
||||
const FirmwareFileSelectionResult._({
|
||||
this.firmware,
|
||||
this.failure,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? firmware;
|
||||
final FirmwareSelectionFailure? failure;
|
||||
|
||||
bool get isSuccess => firmware != null;
|
||||
|
||||
bool get isCanceled =>
|
||||
failure?.reason == FirmwareSelectionFailureReason.canceled;
|
||||
|
||||
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) {
|
||||
return FirmwareFileSelectionResult._(firmware: firmware);
|
||||
}
|
||||
|
||||
static FirmwareFileSelectionResult failed(FirmwareSelectionFailure failure) {
|
||||
return FirmwareFileSelectionResult._(failure: failure);
|
||||
}
|
||||
}
|
||||
@ -10,14 +10,163 @@ const String universalShifterCommandCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
||||
const String universalShifterGearRatiosCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40006';
|
||||
const String universalShifterDfuControlCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40008';
|
||||
const String universalShifterDfuDataCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40009';
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
const int universalShifterDfuOpcodeStart = 0x01;
|
||||
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||
const int universalShifterDfuOpcodeAbort = 0x03;
|
||||
|
||||
const int universalShifterDfuFrameSizeBytes = 64;
|
||||
const int universalShifterDfuFramePayloadSizeBytes = 63;
|
||||
const int universalShifterAttWriteOverheadBytes = 3;
|
||||
const int universalShifterDfuMinimumMtu =
|
||||
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
||||
const int universalShifterDfuPreferredMtu = 128;
|
||||
|
||||
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||
const int universalShifterDfuFlagSigned = 0x02;
|
||||
const int universalShifterDfuFlagNone = 0x00;
|
||||
|
||||
const int errorSequence = 1;
|
||||
const int errorFtmsMissing = 2;
|
||||
const int errorPairingAuth = 3;
|
||||
const int errorPairingEncrypt = 4;
|
||||
const int errorFtmsRequiredCharMissing = 5;
|
||||
|
||||
enum DfuUpdateState {
|
||||
idle,
|
||||
starting,
|
||||
waitingForAck,
|
||||
transferring,
|
||||
finishing,
|
||||
completed,
|
||||
aborted,
|
||||
failed,
|
||||
}
|
||||
|
||||
class DfuUpdateFlags {
|
||||
const DfuUpdateFlags({
|
||||
this.encrypted = false,
|
||||
this.signed = false,
|
||||
});
|
||||
|
||||
final bool encrypted;
|
||||
final bool signed;
|
||||
|
||||
int get rawValue {
|
||||
var value = 0;
|
||||
if (encrypted) {
|
||||
value |= universalShifterDfuFlagEncrypted;
|
||||
}
|
||||
if (signed) {
|
||||
value |= universalShifterDfuFlagSigned;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static DfuUpdateFlags fromRaw(int rawValue) {
|
||||
return DfuUpdateFlags(
|
||||
encrypted: (rawValue & universalShifterDfuFlagEncrypted) != 0,
|
||||
signed: (rawValue & universalShifterDfuFlagSigned) != 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DfuUpdateProgress {
|
||||
const DfuUpdateProgress({
|
||||
required this.state,
|
||||
required this.totalBytes,
|
||||
required this.sentBytes,
|
||||
required this.lastAckedSequence,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final DfuUpdateState state;
|
||||
final int totalBytes;
|
||||
final int sentBytes;
|
||||
final int lastAckedSequence;
|
||||
final int sessionId;
|
||||
final DfuUpdateFlags flags;
|
||||
final String? errorMessage;
|
||||
|
||||
double get fractionComplete {
|
||||
if (totalBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return sentBytes.clamp(0, totalBytes) / totalBytes;
|
||||
}
|
||||
|
||||
int get percentComplete => (fractionComplete * 100).round();
|
||||
|
||||
bool get isTerminal =>
|
||||
state == DfuUpdateState.completed ||
|
||||
state == DfuUpdateState.aborted ||
|
||||
state == DfuUpdateState.failed;
|
||||
}
|
||||
|
||||
enum DfuPreflightFailureReason {
|
||||
deviceNotConnected,
|
||||
wrongConnectedDevice,
|
||||
mtuRequestFailed,
|
||||
mtuTooLow,
|
||||
}
|
||||
|
||||
class DfuPreflightResult {
|
||||
const DfuPreflightResult._({
|
||||
required this.requestedMtu,
|
||||
required this.requiredMtu,
|
||||
required this.negotiatedMtu,
|
||||
required this.failureReason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final int requestedMtu;
|
||||
final int requiredMtu;
|
||||
final int? negotiatedMtu;
|
||||
final DfuPreflightFailureReason? failureReason;
|
||||
final String? message;
|
||||
|
||||
bool get canStart => failureReason == null;
|
||||
|
||||
static DfuPreflightResult ready({
|
||||
required int requestedMtu,
|
||||
required int negotiatedMtu,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: null,
|
||||
message: null,
|
||||
);
|
||||
}
|
||||
|
||||
static DfuPreflightResult failed({
|
||||
required int requestedMtu,
|
||||
required DfuPreflightFailureReason failureReason,
|
||||
required String message,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
int? negotiatedMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: failureReason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShifterErrorInfo {
|
||||
const ShifterErrorInfo({
|
||||
required this.code,
|
||||
@ -112,6 +261,7 @@ enum TrainerConnectionState {
|
||||
idle,
|
||||
connecting,
|
||||
pairing,
|
||||
connected,
|
||||
discoveringFtms,
|
||||
ftmsReady,
|
||||
error,
|
||||
@ -131,6 +281,8 @@ class TrainerStatus {
|
||||
return 'Connecting';
|
||||
case TrainerConnectionState.pairing:
|
||||
return 'Pairing';
|
||||
case TrainerConnectionState.connected:
|
||||
return 'Connected';
|
||||
case TrainerConnectionState.discoveringFtms:
|
||||
return 'Discovering FTMS';
|
||||
case TrainerConnectionState.ftmsReady:
|
||||
@ -148,9 +300,11 @@ class TrainerStatus {
|
||||
case 2:
|
||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||
case 3:
|
||||
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||
case 4:
|
||||
return const TrainerStatus(
|
||||
state: TrainerConnectionState.discoveringFtms);
|
||||
case 4:
|
||||
case 5:
|
||||
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
||||
default:
|
||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||
@ -160,7 +314,7 @@ class TrainerStatus {
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
final variant = raw.first;
|
||||
final value = raw.length > 1 ? raw[1] : null;
|
||||
if (variant is int && variant == 5) {
|
||||
if (variant is int && (variant == 5 || variant == 6)) {
|
||||
return TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
errorCode: value is int ? value : null,
|
||||
@ -173,7 +327,7 @@ class TrainerStatus {
|
||||
if (entry != null) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
if ((key is int && key == 5) ||
|
||||
if ((key is int && (key == 5 || key == 6)) ||
|
||||
(key is String && key.toLowerCase().contains('error'))) {
|
||||
return TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
@ -191,6 +345,9 @@ class TrainerStatus {
|
||||
if (normalized.contains('pairing')) {
|
||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||
}
|
||||
if (normalized.contains('connected')) {
|
||||
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||
}
|
||||
if (normalized.contains('discover')) {
|
||||
return const TrainerStatus(
|
||||
state: TrainerConnectionState.discoveringFtms);
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
||||
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||
@ -62,10 +65,48 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
bool _hasLoadedGearRatios = false;
|
||||
String? _gearRatiosError;
|
||||
List<double> _gearRatios = const [];
|
||||
int _defaultGearIndex = 0;
|
||||
|
||||
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
||||
FirmwareUpdateService? _firmwareUpdateService;
|
||||
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
||||
DfuV1PreparedFirmware? _selectedFirmware;
|
||||
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: 0xFF,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
bool _isSelectingFirmware = false;
|
||||
bool _isStartingFirmwareUpdate = false;
|
||||
String? _firmwareUserMessage;
|
||||
|
||||
bool get _isFirmwareUpdateBusy {
|
||||
if (_isStartingFirmwareUpdate) {
|
||||
return true;
|
||||
}
|
||||
switch (_dfuProgress.state) {
|
||||
case DfuUpdateState.starting:
|
||||
case DfuUpdateState.waitingForAck:
|
||||
case DfuUpdateState.transferring:
|
||||
case DfuUpdateState.finishing:
|
||||
return true;
|
||||
case DfuUpdateState.idle:
|
||||
case DfuUpdateState.completed:
|
||||
case DfuUpdateState.aborted:
|
||||
case DfuUpdateState.failed:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_firmwareFileSelectionService = FirmwareFileSelectionService(
|
||||
filePicker: LocalFirmwareFilePicker(),
|
||||
);
|
||||
_connectionStatusSubscription =
|
||||
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
|
||||
connectionStatusProvider,
|
||||
@ -87,6 +128,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_connectionStatusSubscription?.close();
|
||||
_statusSubscription?.cancel();
|
||||
_shifterService?.dispose();
|
||||
_firmwareProgressSubscription?.cancel();
|
||||
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -99,6 +142,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_isExitingPage = true;
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
|
||||
await _firmwareUpdateService?.cancelUpdate();
|
||||
await _disposeFirmwareUpdateService();
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
await bluetooth?.disconnect();
|
||||
await _stopStatusStreaming();
|
||||
@ -126,11 +172,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
if (_wasConnectedToCurrentDevice &&
|
||||
!_isReconnecting &&
|
||||
status == ConnectionStatus.disconnected) {
|
||||
status == ConnectionStatus.disconnected &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_startReconnect();
|
||||
}
|
||||
|
||||
if (!isCurrentDevice || status == ConnectionStatus.disconnected) {
|
||||
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_stopStatusStreaming();
|
||||
}
|
||||
}
|
||||
@ -160,6 +208,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
Future<void> _startStatusStreamingIfNeeded() async {
|
||||
if (_shifterService != null) {
|
||||
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_recordStatus(status);
|
||||
});
|
||||
_shifterService!.startStatusNotifications();
|
||||
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
|
||||
unawaited(_loadGearRatios());
|
||||
}
|
||||
@ -215,13 +270,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
Future<void> _stopStatusStreaming() async {
|
||||
await _statusSubscription?.cancel();
|
||||
_statusSubscription = null;
|
||||
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _disposeFirmwareUpdateService();
|
||||
await _shifterService?.dispose();
|
||||
_shifterService = null;
|
||||
}
|
||||
|
||||
Future<void> _disposeFirmwareUpdateService() async {
|
||||
await _firmwareProgressSubscription?.cancel();
|
||||
_firmwareProgressSubscription = null;
|
||||
await _firmwareUpdateService?.dispose();
|
||||
_firmwareUpdateService = null;
|
||||
}
|
||||
|
||||
Future<void> _loadGearRatios() async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null || _isGearRatiosLoading) {
|
||||
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -245,20 +313,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_gearRatios = result.unwrap();
|
||||
final data = result.unwrap();
|
||||
_gearRatios = data.ratios;
|
||||
_defaultGearIndex = data.defaultGearIndex;
|
||||
_isGearRatiosLoading = false;
|
||||
_hasLoadedGearRatios = true;
|
||||
_gearRatiosError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _saveGearRatios(List<double> ratios) async {
|
||||
Future<String?> _saveGearRatios(
|
||||
List<double> ratios, int defaultGearIndex) async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
return 'Gear ratio changes are disabled during firmware update.';
|
||||
}
|
||||
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
return 'Status channel is not ready yet.';
|
||||
}
|
||||
|
||||
final result = await shifter.writeGearRatios(ratios);
|
||||
final result = await shifter.writeGearRatios(
|
||||
GearRatiosData(
|
||||
ratios: ratios,
|
||||
defaultGearIndex: defaultGearIndex,
|
||||
),
|
||||
);
|
||||
if (result.isErr()) {
|
||||
return 'Could not save gear ratios: ${result.unwrapErr()}';
|
||||
}
|
||||
@ -269,6 +349,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
|
||||
setState(() {
|
||||
_gearRatios = List<double>.from(ratios);
|
||||
_defaultGearIndex = ratios.isEmpty
|
||||
? 0
|
||||
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
|
||||
_hasLoadedGearRatios = true;
|
||||
_gearRatiosError = null;
|
||||
});
|
||||
@ -277,6 +360,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<void> _connectButtonToBike() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Connect to bike is disabled during firmware updates.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
@ -316,6 +408,177 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null) {
|
||||
return null;
|
||||
}
|
||||
if (_firmwareUpdateService != null) {
|
||||
return _firmwareUpdateService;
|
||||
}
|
||||
|
||||
final asyncBluetooth = ref.read(bluetoothProvider);
|
||||
final bluetooth = asyncBluetooth.valueOrNull;
|
||||
if (bluetooth == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final service = FirmwareUpdateService(
|
||||
transport: ShifterFirmwareUpdateTransport(
|
||||
shifterService: shifter,
|
||||
bluetoothController: bluetooth,
|
||||
buttonDeviceId: widget.deviceAddress,
|
||||
),
|
||||
);
|
||||
|
||||
_firmwareProgressSubscription = service.progressStream.listen((progress) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dfuProgress = progress;
|
||||
if (progress.state == DfuUpdateState.failed &&
|
||||
progress.errorMessage != null) {
|
||||
_firmwareUserMessage = progress.errorMessage;
|
||||
}
|
||||
if (progress.state == DfuUpdateState.completed) {
|
||||
_firmwareUserMessage =
|
||||
'Firmware update completed. The button rebooted and reconnected.';
|
||||
}
|
||||
if (progress.state == DfuUpdateState.aborted) {
|
||||
_firmwareUserMessage = 'Firmware update canceled.';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_firmwareUpdateService = service;
|
||||
return service;
|
||||
}
|
||||
|
||||
Future<void> _selectFirmwareFile() async {
|
||||
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSelectingFirmware = true;
|
||||
_firmwareUserMessage = null;
|
||||
});
|
||||
|
||||
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSelectingFirmware = false;
|
||||
if (result.isSuccess) {
|
||||
_selectedFirmware = result.firmware;
|
||||
_firmwareUserMessage =
|
||||
'Selected ${result.firmware!.fileName}. Ready to start update.';
|
||||
} else if (!result.isCanceled) {
|
||||
_firmwareUserMessage = result.failure?.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startFirmwareUpdate() async {
|
||||
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
||||
return;
|
||||
}
|
||||
|
||||
final firmware = _selectedFirmware;
|
||||
if (firmware == null) {
|
||||
setState(() {
|
||||
_firmwareUserMessage =
|
||||
'Select a firmware .bin file before starting the update.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await _startStatusStreamingIfNeeded();
|
||||
final updater = await _ensureFirmwareUpdateService();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (updater == null) {
|
||||
setState(() {
|
||||
_firmwareUserMessage =
|
||||
'Firmware updater is not ready. Ensure the button is connected.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isStartingFirmwareUpdate = true;
|
||||
_firmwareUserMessage =
|
||||
'Starting update. Keep this screen open and stay near the button.';
|
||||
});
|
||||
|
||||
final result = await updater.startUpdate(
|
||||
imageBytes: firmware.fileBytes,
|
||||
sessionId: firmware.metadata.sessionId,
|
||||
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isStartingFirmwareUpdate = false;
|
||||
if (result.isErr()) {
|
||||
_firmwareUserMessage = result.unwrapErr().toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelFirmwareUpdate() async {
|
||||
final updater = _firmwareUpdateService;
|
||||
if (updater == null || !_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_firmwareUserMessage = 'Canceling firmware update...';
|
||||
});
|
||||
await updater.cancelUpdate();
|
||||
}
|
||||
|
||||
String _dfuPhaseText(DfuUpdateState state) {
|
||||
switch (state) {
|
||||
case DfuUpdateState.idle:
|
||||
return 'Idle';
|
||||
case DfuUpdateState.starting:
|
||||
return 'Sending START command';
|
||||
case DfuUpdateState.waitingForAck:
|
||||
return 'Waiting for ACK from button';
|
||||
case DfuUpdateState.transferring:
|
||||
return 'Transferring firmware frames';
|
||||
case DfuUpdateState.finishing:
|
||||
return 'Finalizing update and waiting for reboot/reconnect';
|
||||
case DfuUpdateState.completed:
|
||||
return 'Update completed';
|
||||
case DfuUpdateState.aborted:
|
||||
return 'Update canceled';
|
||||
case DfuUpdateState.failed:
|
||||
return 'Update failed';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) {
|
||||
return '$bytes B';
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||
}
|
||||
|
||||
String _hexByte(int value) {
|
||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
||||
await _disconnectOnClose();
|
||||
|
||||
@ -453,6 +716,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
final isCurrentConnected = connectionData != null &&
|
||||
connectionData.$1 == ConnectionStatus.connected &&
|
||||
connectionData.$2 == widget.deviceAddress;
|
||||
final canSelectFirmware =
|
||||
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = isCurrentConnected &&
|
||||
!_isSelectingFirmware &&
|
||||
!_isFirmwareUpdateBusy &&
|
||||
_selectedFirmware != null;
|
||||
final canCancelFirmware = _isFirmwareUpdateBusy;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
@ -493,30 +763,57 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GearRatioEditorCard(
|
||||
ratios: _gearRatios,
|
||||
isLoading: _isGearRatiosLoading,
|
||||
errorText: _gearRatiosError,
|
||||
onRetry: _loadGearRatios,
|
||||
onSave: _saveGearRatios,
|
||||
presets: const [
|
||||
GearRatioPreset(
|
||||
name: 'KeAnt Classic',
|
||||
description:
|
||||
'17-step baseline from KeAnt cross app gearing.',
|
||||
ratios: _keAntRatios,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _connectButtonToBike,
|
||||
onPressed:
|
||||
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Connect Button to Bike'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
canCancel: canCancelFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
onCancelUpdate: _cancelFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Opacity(
|
||||
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
|
||||
child: AbsorbPointer(
|
||||
absorbing: _isFirmwareUpdateBusy,
|
||||
child: GearRatioEditorCard(
|
||||
ratios: _gearRatios,
|
||||
defaultGearIndex: _defaultGearIndex,
|
||||
isLoading: _isGearRatiosLoading,
|
||||
errorText: _gearRatiosError,
|
||||
onRetry:
|
||||
_isFirmwareUpdateBusy ? null : _loadGearRatios,
|
||||
onSave: _saveGearRatios,
|
||||
presets: const [
|
||||
GearRatioPreset(
|
||||
name: 'KeAnt Classic',
|
||||
description:
|
||||
'17-step baseline from KeAnt cross app gearing.',
|
||||
ratios: _keAntRatios,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -570,6 +867,159 @@ class _StatusHistoryEntry {
|
||||
final CentralStatus status;
|
||||
}
|
||||
|
||||
class _FirmwareUpdateCard extends StatelessWidget {
|
||||
const _FirmwareUpdateCard({
|
||||
required this.selectedFirmware,
|
||||
required this.progress,
|
||||
required this.isSelecting,
|
||||
required this.isStarting,
|
||||
required this.canSelect,
|
||||
required this.canStart,
|
||||
required this.canCancel,
|
||||
required this.phaseText,
|
||||
required this.statusText,
|
||||
required this.formattedProgressBytes,
|
||||
required this.ackSequenceHex,
|
||||
required this.onSelectFirmware,
|
||||
required this.onStartUpdate,
|
||||
required this.onCancelUpdate,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? selectedFirmware;
|
||||
final DfuUpdateProgress progress;
|
||||
final bool isSelecting;
|
||||
final bool isStarting;
|
||||
final bool canSelect;
|
||||
final bool canStart;
|
||||
final bool canCancel;
|
||||
final String phaseText;
|
||||
final String? statusText;
|
||||
final String formattedProgressBytes;
|
||||
final String ackSequenceHex;
|
||||
final Future<void> Function() onSelectFirmware;
|
||||
final Future<void> Function() onStartUpdate;
|
||||
final Future<void> Function() onCancelUpdate;
|
||||
|
||||
bool get _showProgress {
|
||||
return progress.totalBytes > 0 ||
|
||||
progress.sentBytes > 0 ||
|
||||
progress.state != DfuUpdateState.idle;
|
||||
}
|
||||
|
||||
bool get _showRebootExpectation {
|
||||
return progress.state == DfuUpdateState.finishing ||
|
||||
progress.state == DfuUpdateState.completed;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: canSelect ? onSelectFirmware : null,
|
||||
icon: isSelecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStart ? onStartUpdate : null,
|
||||
icon: isStarting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: canCancel ? onCancelUpdate : null,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
label: const Text('Cancel Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||
if (_showProgress) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBanner extends StatelessWidget {
|
||||
const _StatusBanner({
|
||||
required this.status,
|
||||
|
||||
137
lib/service/dfu_protocol.dart
Normal file
137
lib/service/dfu_protocol.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
|
||||
const int _startPayloadLength = 11;
|
||||
|
||||
class DfuStartPayload {
|
||||
const DfuStartPayload({
|
||||
required this.totalLength,
|
||||
required this.imageCrc32,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int imageCrc32;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuDataFrame {
|
||||
const DfuDataFrame({
|
||||
required this.sequence,
|
||||
required this.offset,
|
||||
required this.payloadLength,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final int sequence;
|
||||
final int offset;
|
||||
final int payloadLength;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
class DfuProtocol {
|
||||
const DfuProtocol._();
|
||||
|
||||
static Uint8List encodeStartPayload(DfuStartPayload payload) {
|
||||
final data = ByteData(_startPayloadLength);
|
||||
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||
data.setUint32(1, payload.totalLength, Endian.little);
|
||||
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||
data.setUint8(9, payload.sessionId);
|
||||
data.setUint8(10, payload.flags);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List encodeFinishPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
|
||||
}
|
||||
|
||||
static Uint8List encodeAbortPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
|
||||
}
|
||||
|
||||
static List<DfuDataFrame> buildDataFrames(
|
||||
List<int> imageBytes, {
|
||||
int startSequence = 0,
|
||||
}) {
|
||||
final frames = <DfuDataFrame>[];
|
||||
var seq = _asU8(startSequence);
|
||||
var offset = 0;
|
||||
while (offset < imageBytes.length) {
|
||||
final remaining = imageBytes.length - offset;
|
||||
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
||||
? remaining
|
||||
: universalShifterDfuFramePayloadSizeBytes;
|
||||
|
||||
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
||||
frame[0] = seq;
|
||||
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
||||
|
||||
frames.add(
|
||||
DfuDataFrame(
|
||||
sequence: seq,
|
||||
offset: offset,
|
||||
payloadLength: chunkLength,
|
||||
bytes: frame,
|
||||
),
|
||||
);
|
||||
|
||||
offset += chunkLength;
|
||||
seq = nextSequence(seq);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
static int nextSequence(int sequence) {
|
||||
return _asU8(sequence + 1);
|
||||
}
|
||||
|
||||
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
||||
return nextSequence(acknowledgedSequence);
|
||||
}
|
||||
|
||||
static int sequenceDistance(int from, int to) {
|
||||
return _asU8(to - from);
|
||||
}
|
||||
|
||||
static int parseAckPayload(List<int> payload) {
|
||||
if (payload.length != 1) {
|
||||
throw const FormatException('ACK payload must be exactly 1 byte.');
|
||||
}
|
||||
return _asU8(payload.first);
|
||||
}
|
||||
|
||||
static const int crc32Initial = 0xFFFFFFFF;
|
||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||
|
||||
static int crc32Update(int crc, List<int> bytes) {
|
||||
var next = crc & 0xFFFFFFFF;
|
||||
for (final byte in bytes) {
|
||||
next ^= byte;
|
||||
for (var bit = 0; bit < 8; bit++) {
|
||||
if ((next & 0x1) != 0) {
|
||||
next = (next >> 1) ^ _crc32PolynomialReflected;
|
||||
} else {
|
||||
next >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32Finalize(int crc) {
|
||||
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32(List<int> bytes) {
|
||||
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||
}
|
||||
|
||||
static int _asU8(int value) {
|
||||
return value & 0xFF;
|
||||
}
|
||||
}
|
||||
154
lib/service/firmware_file_selection_service.dart
Normal file
154
lib/service/firmware_file_selection_service.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
typedef SessionIdGenerator = int Function();
|
||||
|
||||
class FirmwarePickerSelection {
|
||||
const FirmwarePickerSelection({
|
||||
required this.fileName,
|
||||
required this.fileBytes,
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final Uint8List fileBytes;
|
||||
final String? filePath;
|
||||
}
|
||||
|
||||
abstract interface class FirmwareFilePicker {
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile();
|
||||
}
|
||||
|
||||
class LocalFirmwareFilePicker implements FirmwareFilePicker {
|
||||
@override
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile() async {
|
||||
final pickResult = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: false,
|
||||
withData: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['bin'],
|
||||
);
|
||||
if (pickResult == null) {
|
||||
return null;
|
||||
}
|
||||
if (pickResult.files.isEmpty) {
|
||||
return FirmwarePickerSelection(
|
||||
fileName: '',
|
||||
fileBytes: Uint8List(0),
|
||||
);
|
||||
}
|
||||
|
||||
final selected = pickResult.files.first;
|
||||
final bytes = selected.bytes ?? await _readFromPath(selected.path);
|
||||
|
||||
return FirmwarePickerSelection(
|
||||
fileName: selected.name,
|
||||
filePath: selected.path,
|
||||
fileBytes: bytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _readFromPath(String? path) async {
|
||||
if (path == null || path.trim().isEmpty) {
|
||||
throw const FileSystemException(
|
||||
'Selected file did not contain readable bytes or a valid path.',
|
||||
);
|
||||
}
|
||||
final file = File(path);
|
||||
return file.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
class FirmwareFileSelectionService {
|
||||
FirmwareFileSelectionService({
|
||||
required FirmwareFilePicker filePicker,
|
||||
SessionIdGenerator? sessionIdGenerator,
|
||||
}) : _filePicker = filePicker,
|
||||
_sessionIdGenerator = sessionIdGenerator ?? _randomSessionId;
|
||||
|
||||
final FirmwareFilePicker _filePicker;
|
||||
final SessionIdGenerator _sessionIdGenerator;
|
||||
|
||||
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
|
||||
final FirmwarePickerSelection? selection;
|
||||
try {
|
||||
selection = await _filePicker.pickFirmwareFile();
|
||||
} catch (error) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.readFailed,
|
||||
message: 'Could not read selected firmware file: $error',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selection == null) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
const FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.canceled,
|
||||
message: 'Firmware selection canceled.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final fileName = selection.fileName.trim();
|
||||
if (fileName.isEmpty) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
const FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.malformedSelection,
|
||||
message: 'Selected firmware file is missing a valid name.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_hasBinExtension(fileName)) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.unsupportedExtension,
|
||||
message:
|
||||
'Unsupported firmware file "$fileName". Please select a .bin file.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.fileBytes.isEmpty) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.emptyFile,
|
||||
message:
|
||||
'Selected firmware file "$fileName" is empty. Choose a non-empty .bin file.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final metadata = DfuV1FirmwareMetadata(
|
||||
totalLength: selection.fileBytes.length,
|
||||
crc32: DfuProtocol.crc32(selection.fileBytes),
|
||||
sessionId: _sessionIdGenerator() & 0xFF,
|
||||
flags: universalShifterDfuFlagNone,
|
||||
);
|
||||
|
||||
return FirmwareFileSelectionResult.success(
|
||||
DfuV1PreparedFirmware(
|
||||
fileName: fileName,
|
||||
filePath: selection.filePath,
|
||||
fileBytes: selection.fileBytes,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasBinExtension(String fileName) {
|
||||
return fileName.toLowerCase().endsWith('.bin');
|
||||
}
|
||||
|
||||
static int _randomSessionId() {
|
||||
return Random.secure().nextInt(256);
|
||||
}
|
||||
}
|
||||
690
lib/service/firmware_update_service.dart
Normal file
690
lib/service/firmware_update_service.dart
Normal file
@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
|
||||
const int _initialAckSequence = 0xFF;
|
||||
|
||||
class FirmwareUpdateService {
|
||||
FirmwareUpdateService({
|
||||
required FirmwareUpdateTransport transport,
|
||||
this.defaultWindowSize = 8,
|
||||
this.maxNoProgressRetries = 5,
|
||||
this.defaultAckTimeout = const Duration(milliseconds: 800),
|
||||
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
||||
this.defaultReconnectTimeout = const Duration(seconds: 12),
|
||||
this.defaultVerificationTimeout = const Duration(seconds: 5),
|
||||
}) : _transport = transport;
|
||||
|
||||
final FirmwareUpdateTransport _transport;
|
||||
final int defaultWindowSize;
|
||||
final int maxNoProgressRetries;
|
||||
final Duration defaultAckTimeout;
|
||||
final Duration defaultPostFinishResetTimeout;
|
||||
final Duration defaultReconnectTimeout;
|
||||
final Duration defaultVerificationTimeout;
|
||||
|
||||
final StreamController<DfuUpdateProgress> _progressController =
|
||||
StreamController<DfuUpdateProgress>.broadcast();
|
||||
|
||||
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
|
||||
StreamSubscription<List<int>>? _ackSubscription;
|
||||
Completer<void>? _ackSignal;
|
||||
Completer<void>? _cancelSignal;
|
||||
int _ackEventCount = 0;
|
||||
String? _ackStreamError;
|
||||
bool _isRunning = false;
|
||||
bool _cancelRequested = false;
|
||||
int _latestAckSequence = _initialAckSequence;
|
||||
int _ackedFrames = 0;
|
||||
int _totalFrames = 0;
|
||||
int _totalBytes = 0;
|
||||
|
||||
Stream<DfuUpdateProgress> get progressStream => _progressController.stream;
|
||||
|
||||
DfuUpdateProgress get currentProgress => _currentProgress;
|
||||
|
||||
bool get isUpdating => _isRunning;
|
||||
|
||||
Future<Result<void>> startUpdate({
|
||||
required List<int> imageBytes,
|
||||
required int sessionId,
|
||||
DfuUpdateFlags flags = const DfuUpdateFlags(),
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
int? windowSize,
|
||||
Duration? ackTimeout,
|
||||
int? noProgressRetries,
|
||||
Duration? postFinishResetTimeout,
|
||||
Duration? reconnectTimeout,
|
||||
Duration? verificationTimeout,
|
||||
}) async {
|
||||
if (_isRunning) {
|
||||
return bail(
|
||||
'Firmware update is already running. Cancel or wait for completion before starting a new upload.');
|
||||
}
|
||||
if (imageBytes.isEmpty) {
|
||||
return bail(
|
||||
'Firmware image is empty. Select a valid .bin file and retry.');
|
||||
}
|
||||
|
||||
final effectiveWindowSize = windowSize ?? defaultWindowSize;
|
||||
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
|
||||
final effectiveNoProgressRetries =
|
||||
noProgressRetries ?? maxNoProgressRetries;
|
||||
final effectivePostFinishResetTimeout =
|
||||
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
|
||||
final effectiveReconnectTimeout =
|
||||
reconnectTimeout ?? defaultReconnectTimeout;
|
||||
final effectiveVerificationTimeout =
|
||||
verificationTimeout ?? defaultVerificationTimeout;
|
||||
|
||||
if (effectiveWindowSize <= 0) {
|
||||
return bail(
|
||||
'DFU window size must be at least 1 frame. Got $effectiveWindowSize.');
|
||||
}
|
||||
if (effectiveNoProgressRetries < 0) {
|
||||
return bail(
|
||||
'No-progress retry limit cannot be negative. Got $effectiveNoProgressRetries.');
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = Completer<void>();
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _initialAckSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames =
|
||||
(imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/
|
||||
universalShifterDfuFramePayloadSizeBytes;
|
||||
_totalBytes = imageBytes.length;
|
||||
|
||||
final normalizedSessionId = sessionId & 0xFF;
|
||||
final crc32 = DfuProtocol.crc32(imageBytes);
|
||||
final frames = DfuProtocol.buildDataFrames(imageBytes);
|
||||
var shouldAbortForCleanup = false;
|
||||
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.starting,
|
||||
totalBytes: imageBytes.length,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags,
|
||||
);
|
||||
|
||||
try {
|
||||
final preflightResult = await _transport.runPreflight(
|
||||
requestedMtu: requestedMtu,
|
||||
);
|
||||
if (preflightResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'DFU preflight check failed due to transport error: ${preflightResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
final preflight = preflightResult.unwrap();
|
||||
if (!preflight.canStart) {
|
||||
throw _DfuFailure(
|
||||
preflight.message ??
|
||||
'DFU preflight failed. Ensure button connection and MTU are ready, then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = _transport.subscribeToAck().listen(
|
||||
_handleAckPayload,
|
||||
onError: (Object error) {
|
||||
_ackStreamError =
|
||||
'ACK indication stream failed: $error. Reconnect and retry the update.';
|
||||
_signalAckWaiters();
|
||||
},
|
||||
);
|
||||
|
||||
_emitProgress(state: DfuUpdateState.waitingForAck);
|
||||
final startEventCount = _ackEventCount;
|
||||
final startWriteResult = await _transport.writeControl(
|
||||
DfuProtocol.encodeStartPayload(
|
||||
DfuStartPayload(
|
||||
totalLength: imageBytes.length,
|
||||
imageCrc32: crc32,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags.rawValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (startWriteResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed to send DFU START command: ${startWriteResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
shouldAbortForCleanup = true;
|
||||
|
||||
final initialAck = await _waitForInitialAck(
|
||||
afterEventCount: startEventCount,
|
||||
timeout: effectiveAckTimeout,
|
||||
);
|
||||
if (initialAck != _initialAckSequence) {
|
||||
throw _DfuFailure(
|
||||
'Device did not acknowledge START correctly (expected ACK 0xFF, got 0x${initialAck.toRadixString(16).padLeft(2, '0').toUpperCase()}). Send ABORT, reconnect if needed, and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
_emitProgress(state: DfuUpdateState.transferring);
|
||||
|
||||
var nextFrameIndex = 0;
|
||||
var retriesWithoutProgress = 0;
|
||||
|
||||
while (_ackedFrames < _totalFrames) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
|
||||
final ackedBeforeWindow = _ackedFrames;
|
||||
final endExclusive =
|
||||
(nextFrameIndex + effectiveWindowSize).clamp(0, frames.length);
|
||||
|
||||
for (var frameIndex = nextFrameIndex;
|
||||
frameIndex < endExclusive;
|
||||
frameIndex++) {
|
||||
_throwIfCancelled();
|
||||
final writeResult =
|
||||
await _transport.writeDataFrame(frames[frameIndex].bytes);
|
||||
if (writeResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed sending DFU data frame #$frameIndex (seq 0x${frames[frameIndex].sequence.toRadixString(16).padLeft(2, '0').toUpperCase()}): ${writeResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
nextFrameIndex = endExclusive;
|
||||
|
||||
if (_ackedFrames > ackedBeforeWindow) {
|
||||
retriesWithoutProgress = 0;
|
||||
nextFrameIndex = _ackedFrames;
|
||||
continue;
|
||||
}
|
||||
|
||||
final gotProgress = await _waitForAckProgress(
|
||||
ackedFramesBeforeWait: ackedBeforeWindow,
|
||||
timeout: effectiveAckTimeout,
|
||||
);
|
||||
|
||||
if (gotProgress) {
|
||||
retriesWithoutProgress = 0;
|
||||
nextFrameIndex = _ackedFrames;
|
||||
continue;
|
||||
}
|
||||
|
||||
retriesWithoutProgress += 1;
|
||||
if (retriesWithoutProgress > effectiveNoProgressRetries) {
|
||||
throw _DfuFailure(
|
||||
'Upload stalled: no ACK progress after $retriesWithoutProgress retries (last ACK 0x${_latestAckSequence.toRadixString(16).padLeft(2, '0').toUpperCase()}). Check BLE signal quality and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
nextFrameIndex = _ackedFrames;
|
||||
}
|
||||
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.finishing, sentBytes: imageBytes.length);
|
||||
final finishResult =
|
||||
await _transport.writeControl(DfuProtocol.encodeFinishPayload());
|
||||
if (finishResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed to send DFU FINISH command: ${finishResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
|
||||
final resetDisconnectResult =
|
||||
await _transport.waitForExpectedResetDisconnect(
|
||||
timeout: effectivePostFinishResetTimeout,
|
||||
);
|
||||
if (resetDisconnectResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
final reconnectResult = await _transport.reconnectForVerification(
|
||||
timeout: effectiveReconnectTimeout,
|
||||
);
|
||||
if (reconnectResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device did not reconnect after DFU reset: ${reconnectResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
final verificationResult = await _transport.verifyDeviceReachable(
|
||||
timeout: effectiveVerificationTimeout,
|
||||
);
|
||||
if (verificationResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device reconnected but post-update verification failed: ${verificationResult.unwrapErr()} '
|
||||
'Firmware version cannot be compared yet because the device does not expose a version characteristic.',
|
||||
);
|
||||
}
|
||||
|
||||
shouldAbortForCleanup = false;
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
|
||||
return Ok(null);
|
||||
} on _DfuCancelled {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
_emitProgress(state: DfuUpdateState.aborted);
|
||||
return bail('Firmware update canceled by user.');
|
||||
} on _DfuFailure catch (failure) {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.failed, errorMessage: failure.message);
|
||||
return bail(failure.message);
|
||||
} catch (error) {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
final message =
|
||||
'Firmware update failed unexpectedly: $error. Reconnect to the button and retry.';
|
||||
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
|
||||
return bail(message);
|
||||
} finally {
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
_isRunning = false;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = null;
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _currentProgress.lastAckedSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames = 0;
|
||||
_totalBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelUpdate() async {
|
||||
if (!_isRunning || _cancelRequested) {
|
||||
return;
|
||||
}
|
||||
_cancelRequested = true;
|
||||
_cancelSignal?.complete();
|
||||
_signalAckWaiters();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await cancelUpdate();
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
await _progressController.close();
|
||||
}
|
||||
|
||||
void _handleAckPayload(List<int> payload) {
|
||||
try {
|
||||
final sequence = DfuProtocol.parseAckPayload(payload);
|
||||
final previousAck = _latestAckSequence;
|
||||
_latestAckSequence = sequence;
|
||||
|
||||
if (_totalFrames > 0 &&
|
||||
_currentProgress.state == DfuUpdateState.transferring) {
|
||||
final delta = DfuProtocol.sequenceDistance(previousAck, sequence);
|
||||
if (delta > 0) {
|
||||
_ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames);
|
||||
}
|
||||
|
||||
_emitProgress(
|
||||
lastAckedSequence: sequence,
|
||||
sentBytes:
|
||||
_ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes),
|
||||
);
|
||||
} else {
|
||||
_emitProgress(lastAckedSequence: sequence);
|
||||
}
|
||||
} on FormatException catch (error) {
|
||||
_ackStreamError =
|
||||
'Received malformed ACK indication: $error. Reconnect and retry.';
|
||||
} finally {
|
||||
_ackEventCount += 1;
|
||||
_signalAckWaiters();
|
||||
}
|
||||
}
|
||||
|
||||
void _emitProgress({
|
||||
DfuUpdateState? state,
|
||||
int? totalBytes,
|
||||
int? sentBytes,
|
||||
int? lastAckedSequence,
|
||||
int? sessionId,
|
||||
DfuUpdateFlags? flags,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
final next = DfuUpdateProgress(
|
||||
state: state ?? _currentProgress.state,
|
||||
totalBytes: totalBytes ?? _currentProgress.totalBytes,
|
||||
sentBytes: sentBytes ?? _currentProgress.sentBytes,
|
||||
lastAckedSequence:
|
||||
lastAckedSequence ?? _currentProgress.lastAckedSequence,
|
||||
sessionId: sessionId ?? _currentProgress.sessionId,
|
||||
flags: flags ?? _currentProgress.flags,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
_currentProgress = next;
|
||||
_progressController.add(next);
|
||||
}
|
||||
|
||||
Future<int> _waitForInitialAck({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
var observedEvents = afterEventCount;
|
||||
|
||||
while (true) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
final remaining = deadline.difference(DateTime.now());
|
||||
if (remaining <= Duration.zero) {
|
||||
throw _DfuFailure(
|
||||
'Timed out waiting for initial DFU ACK after START. Ensure indications are enabled and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
final gotEvent = await _waitForNextAckEvent(
|
||||
afterEventCount: observedEvents,
|
||||
timeout: remaining,
|
||||
);
|
||||
if (!gotEvent) {
|
||||
continue;
|
||||
}
|
||||
observedEvents = _ackEventCount;
|
||||
return _latestAckSequence;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForAckProgress({
|
||||
required int ackedFramesBeforeWait,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
var observedEvents = _ackEventCount;
|
||||
|
||||
while (true) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
|
||||
if (_ackedFrames > ackedFramesBeforeWait) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final remaining = deadline.difference(DateTime.now());
|
||||
if (remaining <= Duration.zero) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final gotEvent = await _waitForNextAckEvent(
|
||||
afterEventCount: observedEvents,
|
||||
timeout: remaining,
|
||||
);
|
||||
|
||||
if (!gotEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
observedEvents = _ackEventCount;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForNextAckEvent({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
if (_ackEventCount > afterEventCount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_ackSignal ??= Completer<void>();
|
||||
final signal = _ackSignal!;
|
||||
|
||||
try {
|
||||
await Future.any<void>([
|
||||
signal.future,
|
||||
_cancelSignal?.future ?? Future<void>.value(),
|
||||
]).timeout(timeout);
|
||||
} on TimeoutException {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (identical(_ackSignal, signal)) {
|
||||
_ackSignal = null;
|
||||
}
|
||||
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
return _ackEventCount > afterEventCount;
|
||||
}
|
||||
|
||||
void _throwIfCancelled() {
|
||||
if (_cancelRequested) {
|
||||
throw const _DfuCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfAckStreamErrored() {
|
||||
final error = _ackStreamError;
|
||||
if (error != null) {
|
||||
throw _DfuFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendAbortForCleanup() async {
|
||||
final result =
|
||||
await _transport.writeControl(DfuProtocol.encodeAbortPayload());
|
||||
if (result.isErr()) {
|
||||
final cleanupMessage =
|
||||
'Could not send DFU ABORT during cleanup: ${result.unwrapErr()}';
|
||||
if (_currentProgress.state == DfuUpdateState.failed &&
|
||||
_currentProgress.errorMessage != null) {
|
||||
_emitProgress(
|
||||
errorMessage: '${_currentProgress.errorMessage} $cleanupMessage',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _signalAckWaiters() {
|
||||
final signal = _ackSignal;
|
||||
if (signal != null && !signal.isCompleted) {
|
||||
signal.complete();
|
||||
}
|
||||
}
|
||||
|
||||
int _ackedBytesFromFrames(int ackedFrames, int totalFrames, int totalBytes) {
|
||||
if (totalFrames == 0 || ackedFrames <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (ackedFrames >= totalFrames) {
|
||||
return totalBytes;
|
||||
}
|
||||
return ackedFrames * universalShifterDfuFramePayloadSizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class FirmwareUpdateTransport {
|
||||
Future<Result<DfuPreflightResult>> runPreflight({required int requestedMtu});
|
||||
|
||||
Stream<List<int>> subscribeToAck();
|
||||
|
||||
Future<Result<void>> writeControl(List<int> payload);
|
||||
|
||||
Future<Result<void>> writeDataFrame(List<int> frame);
|
||||
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
});
|
||||
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
});
|
||||
|
||||
/// Verifies that the device is reachable after reconnect.
|
||||
///
|
||||
/// Current limitation: strict firmware version comparison is not possible
|
||||
/// yet because no firmware version characteristic is exposed by the device.
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
});
|
||||
}
|
||||
|
||||
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
ShifterFirmwareUpdateTransport({
|
||||
required this.shifterService,
|
||||
required this.bluetoothController,
|
||||
required this.buttonDeviceId,
|
||||
});
|
||||
|
||||
final ShifterService shifterService;
|
||||
final BluetoothController bluetoothController;
|
||||
final String buttonDeviceId;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) {
|
||||
return shifterService.runDfuPreflight(requestedMtu: requestedMtu);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() {
|
||||
return bluetoothController.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuAckCharacteristicUuid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuControlCharacteristicUuid,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuDataCharacteristicUuid,
|
||||
frame,
|
||||
withResponse: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final currentState = bluetoothController.currentConnectionState;
|
||||
if (currentState.$1 == ConnectionStatus.disconnected) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await bluetoothController.connectionStateStream
|
||||
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
|
||||
.timeout(timeout);
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms waiting for the expected reset disconnect.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Failed while waiting for expected reset disconnect: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final connectResult =
|
||||
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
|
||||
if (connectResult.isErr()) {
|
||||
return bail(connectResult.unwrapErr());
|
||||
}
|
||||
|
||||
final currentState = bluetoothController.currentConnectionState;
|
||||
if (currentState.$1 == ConnectionStatus.connected &&
|
||||
currentState.$2 == buttonDeviceId) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await bluetoothController.connectionStateStream
|
||||
.firstWhere(
|
||||
(state) =>
|
||||
state.$1 == ConnectionStatus.connected &&
|
||||
state.$2 == buttonDeviceId,
|
||||
)
|
||||
.timeout(timeout);
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms waiting for reconnect.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Reconnect wait failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
try {
|
||||
final statusResult = await shifterService.readStatus().timeout(timeout);
|
||||
if (statusResult.isErr()) {
|
||||
return bail(statusResult.unwrapErr());
|
||||
}
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Post-update verification failed: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DfuFailure implements Exception {
|
||||
const _DfuFailure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class _DfuCancelled implements Exception {
|
||||
const _DfuCancelled();
|
||||
}
|
||||
@ -6,12 +6,30 @@ import 'package:anyhow/anyhow.dart';
|
||||
|
||||
class ShifterService {
|
||||
ShifterService({
|
||||
required BluetoothController bluetooth,
|
||||
BluetoothController? bluetooth,
|
||||
required this.buttonDeviceId,
|
||||
}) : _bluetooth = bluetooth;
|
||||
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
|
||||
}) : _bluetooth = bluetooth,
|
||||
_dfuPreflightBluetooth =
|
||||
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
|
||||
if (bluetooth == null && dfuPreflightBluetooth == null) {
|
||||
throw ArgumentError(
|
||||
'Either bluetooth or dfuPreflightBluetooth must be provided.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final BluetoothController _bluetooth;
|
||||
final BluetoothController? _bluetooth;
|
||||
final String buttonDeviceId;
|
||||
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
|
||||
|
||||
BluetoothController get _requireBluetooth {
|
||||
final bluetooth = _bluetooth;
|
||||
if (bluetooth == null) {
|
||||
throw StateError('Bluetooth controller is not available.');
|
||||
}
|
||||
return bluetooth;
|
||||
}
|
||||
|
||||
final StreamController<CentralStatus> _statusController =
|
||||
StreamController<CentralStatus>.broadcast();
|
||||
@ -20,13 +38,15 @@ class ShifterService {
|
||||
Stream<CentralStatus> get statusStream => _statusController.stream;
|
||||
|
||||
static const int _gearRatioSlots = 32;
|
||||
static const int _defaultGearIndexOffset = _gearRatioSlots;
|
||||
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
|
||||
static const double _maxGearRatio = 255 / 64;
|
||||
static const int _gearRatioWriteMtu = 64;
|
||||
|
||||
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
|
||||
try {
|
||||
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
|
||||
return _bluetooth.writeCharacteristic(
|
||||
return _requireBluetooth.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterConnectToAddrCharacteristicUuid,
|
||||
@ -40,7 +60,7 @@ class ShifterService {
|
||||
}
|
||||
|
||||
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
|
||||
return _bluetooth.writeCharacteristic(
|
||||
return _requireBluetooth.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterCommandCharacteristicUuid,
|
||||
@ -56,8 +76,8 @@ class ShifterService {
|
||||
return writeCommand(UniversalShifterCommand.connectToDevice);
|
||||
}
|
||||
|
||||
Future<Result<List<double>>> readGearRatios() async {
|
||||
final readRes = await _bluetooth.readCharacteristic(
|
||||
Future<Result<GearRatiosData>> readGearRatios() async {
|
||||
final readRes = await _requireBluetooth.readCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterGearRatiosCharacteristicUuid,
|
||||
@ -67,40 +87,64 @@ class ShifterService {
|
||||
}
|
||||
|
||||
final raw = readRes.unwrap();
|
||||
if (raw.length > _gearRatioSlots) {
|
||||
if (raw.length > _gearRatioPayloadBytes) {
|
||||
return bail(
|
||||
'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}',
|
||||
'Invalid gear ratio payload length: expected at most $_gearRatioPayloadBytes, got ${raw.length}',
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedRaw = List<int>.filled(_gearRatioSlots, 0, growable: false);
|
||||
final normalizedRaw = List<int>.filled(
|
||||
_gearRatioPayloadBytes,
|
||||
0,
|
||||
growable: false,
|
||||
);
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
normalizedRaw[i] = raw[i];
|
||||
}
|
||||
|
||||
final ratios = normalizedRaw
|
||||
.where((v) => v > 0)
|
||||
.map((v) => _decodeGearRatio(v))
|
||||
.toList(growable: false);
|
||||
return Ok(ratios);
|
||||
final ratios = <double>[];
|
||||
for (var i = 0; i < _gearRatioSlots; i++) {
|
||||
final value = normalizedRaw[i];
|
||||
if (value == 0) {
|
||||
break;
|
||||
}
|
||||
ratios.add(_decodeGearRatio(value));
|
||||
}
|
||||
|
||||
final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset];
|
||||
final defaultGearIndex = ratios.isEmpty
|
||||
? 0
|
||||
: defaultIndexRaw.clamp(0, ratios.length - 1).toInt();
|
||||
return Ok(
|
||||
GearRatiosData(
|
||||
ratios: ratios,
|
||||
defaultGearIndex: defaultGearIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<void>> writeGearRatios(List<double> ratios) async {
|
||||
final mtuResult =
|
||||
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
|
||||
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
|
||||
final mtuResult = await _requireBluetooth.requestMtu(
|
||||
buttonDeviceId,
|
||||
mtu: _gearRatioWriteMtu,
|
||||
);
|
||||
if (mtuResult.isErr()) {
|
||||
return bail(
|
||||
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
|
||||
}
|
||||
|
||||
final payload = List<int>.filled(_gearRatioSlots, 0, growable: false);
|
||||
final payload =
|
||||
List<int>.filled(_gearRatioPayloadBytes, 0, growable: false);
|
||||
final ratios = data.ratios;
|
||||
final limit =
|
||||
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
|
||||
for (var i = 0; i < limit; i++) {
|
||||
payload[i] = _encodeGearRatio(ratios[i]);
|
||||
}
|
||||
payload[_defaultGearIndexOffset] =
|
||||
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
|
||||
|
||||
return _bluetooth.writeCharacteristic(
|
||||
return _requireBluetooth.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterGearRatiosCharacteristicUuid,
|
||||
@ -109,7 +153,7 @@ class ShifterService {
|
||||
}
|
||||
|
||||
Future<Result<CentralStatus>> readStatus() async {
|
||||
final readRes = await _bluetooth.readCharacteristic(
|
||||
final readRes = await _requireBluetooth.readCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterStatusCharacteristicUuid,
|
||||
@ -125,12 +169,78 @@ class ShifterService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<DfuPreflightResult>> runDfuPreflight({
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
}) async {
|
||||
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
|
||||
final connectionStatus = currentConnection.$1;
|
||||
final connectedDeviceId = currentConnection.$2;
|
||||
|
||||
if (connectionStatus != ConnectionStatus.connected ||
|
||||
connectedDeviceId == null) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.deviceNotConnected,
|
||||
message:
|
||||
'No button connection is active. Connect the target button, then retry the firmware update.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (connectedDeviceId != buttonDeviceId) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
|
||||
message:
|
||||
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
|
||||
buttonDeviceId,
|
||||
mtu: requestedMtu,
|
||||
);
|
||||
if (mtuResult.isErr()) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
|
||||
message:
|
||||
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final negotiatedMtu = mtuResult.unwrap();
|
||||
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
|
||||
return Ok(
|
||||
DfuPreflightResult.failed(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: DfuPreflightFailureReason.mtuTooLow,
|
||||
message:
|
||||
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void startStatusNotifications() {
|
||||
if (_statusSubscription != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_statusSubscription = _bluetooth
|
||||
_statusSubscription = _requireBluetooth
|
||||
.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
@ -177,3 +287,39 @@ class ShifterService {
|
||||
return raw / 64.0;
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class DfuPreflightBluetoothAdapter {
|
||||
(ConnectionStatus, String?) get currentConnectionState;
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
});
|
||||
}
|
||||
|
||||
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
|
||||
const _BluetoothDfuPreflightAdapter(this._bluetooth);
|
||||
|
||||
final BluetoothController _bluetooth;
|
||||
|
||||
@override
|
||||
(ConnectionStatus, String?) get currentConnectionState =>
|
||||
_bluetooth.currentConnectionState;
|
||||
|
||||
@override
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
}) {
|
||||
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||
}
|
||||
}
|
||||
|
||||
class GearRatiosData {
|
||||
const GearRatiosData({
|
||||
required this.ratios,
|
||||
required this.defaultGearIndex,
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final int defaultGearIndex;
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ class GearRatioPreset {
|
||||
class GearRatioEditorCard extends StatefulWidget {
|
||||
const GearRatioEditorCard({
|
||||
required this.ratios,
|
||||
required this.defaultGearIndex,
|
||||
required this.isLoading,
|
||||
required this.onSave,
|
||||
required this.presets,
|
||||
@ -26,8 +27,10 @@ class GearRatioEditorCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final int defaultGearIndex;
|
||||
final bool isLoading;
|
||||
final Future<String?> Function(List<double> ratios) onSave;
|
||||
final Future<String?> Function(List<double> ratios, int defaultGearIndex)
|
||||
onSave;
|
||||
final List<GearRatioPreset> presets;
|
||||
final String? errorText;
|
||||
final VoidCallback? onRetry;
|
||||
@ -43,6 +46,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
static const double _sliderPivotV = 1.00;
|
||||
static const Duration _animDuration = Duration(milliseconds: 280);
|
||||
static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0);
|
||||
static const int _maxGears = 32;
|
||||
static const double _inlineAddRadius = 24;
|
||||
static const double _editorTileOverlap = 20;
|
||||
|
||||
double get _inlineAddEdgeOffset => _inlineAddRadius + 2;
|
||||
|
||||
bool _isExpanded = false;
|
||||
bool _isEditing = false;
|
||||
@ -54,20 +62,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
|
||||
List<double> _committed = const [];
|
||||
List<double> _draft = const [];
|
||||
int _committedDefaultGearIndex = 0;
|
||||
int _draftDefaultGearIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_committed = List<double>.from(widget.ratios);
|
||||
_draft = List<double>.from(widget.ratios);
|
||||
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||
widget.defaultGearIndex,
|
||||
_committed.length,
|
||||
);
|
||||
_draftDefaultGearIndex = _committedDefaultGearIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) {
|
||||
if (!_isEditing &&
|
||||
(!_listEquals(oldWidget.ratios, widget.ratios) ||
|
||||
oldWidget.defaultGearIndex != widget.defaultGearIndex)) {
|
||||
_committed = List<double>.from(widget.ratios);
|
||||
_draft = List<double>.from(widget.ratios);
|
||||
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||
widget.defaultGearIndex,
|
||||
_committed.length,
|
||||
);
|
||||
_draftDefaultGearIndex = _committedDefaultGearIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,7 +194,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
child: AnimatedContainer(
|
||||
duration: _animDuration,
|
||||
curve: _animCurve,
|
||||
height: _isExpanded ? 210 : 130,
|
||||
height: _isExpanded ? 240 : 130,
|
||||
child: _GearRatioGraph(
|
||||
ratios: _isEditing ? _draft : _committed,
|
||||
compact: !_isExpanded,
|
||||
@ -183,7 +205,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child: _compactRatioStrip(context, _committed),
|
||||
child: _compactRatioStrip(
|
||||
context,
|
||||
_committed,
|
||||
defaultGearIndex: _committedDefaultGearIndex,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: _animDuration,
|
||||
@ -197,7 +223,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _committed.length; i++)
|
||||
_ratioChip(context, i + 1, _committed[i]),
|
||||
_ratioChip(
|
||||
context,
|
||||
i + 1,
|
||||
_committed[i],
|
||||
isDefault: i == _committedDefaultGearIndex,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -232,8 +263,8 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
if (_draft.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||
child:
|
||||
Text('No ratios yet. Load a preset to start editing.'),
|
||||
child: Text(
|
||||
'No ratios yet. Add a gear or load a preset to start editing.'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
@ -274,23 +305,16 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 12),
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: _animDuration,
|
||||
switchInCurve: _animCurve,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: _snappyTransition,
|
||||
child: Wrap(
|
||||
child: Column(
|
||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _draft.length; i++)
|
||||
KeyedSubtree(
|
||||
key: ValueKey('editor-${i + 1}'),
|
||||
child: _buildGearEditor(context, i),
|
||||
),
|
||||
],
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _buildEditableGearTiles(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -314,73 +338,307 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGearEditor(BuildContext context, int index) {
|
||||
final ratio = _draft[index];
|
||||
final sliderValue = _valueToSlider(ratio);
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 230, maxWidth: 280),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.6),
|
||||
List<Widget> _buildEditableGearTiles(BuildContext context) {
|
||||
if (_draft.isEmpty) {
|
||||
return [_buildStandaloneAddGearButton(context)];
|
||||
}
|
||||
|
||||
final widgets = <Widget>[];
|
||||
for (var i = 0; i < _draft.length; i++) {
|
||||
final editor = KeyedSubtree(
|
||||
key: ValueKey('editor-${i + 1}'),
|
||||
child: _buildGearEditor(context, i),
|
||||
);
|
||||
if (i == 0) {
|
||||
widgets.add(editor);
|
||||
} else {
|
||||
widgets.add(
|
||||
Transform.translate(
|
||||
offset: Offset(0, -i * _editorTileOverlap),
|
||||
child: editor,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Gear ${index + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => _editRatioText(index),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
ratio.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: sliderValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_draft[index] = _quantizeRatio(_sliderToValue(value));
|
||||
});
|
||||
},
|
||||
onChangeEnd: (_) {
|
||||
setState(() {
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
Widget _buildStandaloneAddGearButton(BuildContext context) {
|
||||
final canAdd = _draft.length < _maxGears;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 118,
|
||||
child: Center(
|
||||
child: _buildInlineAddButton(
|
||||
context,
|
||||
afterIndex: -1,
|
||||
canAdd: canAdd,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineAddButton(
|
||||
BuildContext context, {
|
||||
required int afterIndex,
|
||||
required bool canAdd,
|
||||
String? tooltip,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Tooltip(
|
||||
message: canAdd ? 'Add gear' : 'Maximum $_maxGears gears reached',
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Ink(
|
||||
width: _inlineAddRadius * 2,
|
||||
height: _inlineAddRadius * 2,
|
||||
decoration: ShapeDecoration(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
color: theme.colorScheme.surface,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
blurRadius: 12,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: canAdd ? () => _insertGearAfter(afterIndex) : null,
|
||||
icon: const Icon(Icons.add),
|
||||
iconSize: 22,
|
||||
splashRadius: _inlineAddRadius,
|
||||
tooltip: canAdd ? tooltip ?? 'Add gear' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGearEditor(BuildContext context, int index) {
|
||||
final theme = Theme.of(context);
|
||||
final canAdd = _draft.length < _maxGears;
|
||||
final ratio = _draft[index];
|
||||
final sliderValue = _valueToSlider(ratio);
|
||||
final isDefault = index == _draftDefaultGearIndex;
|
||||
final borderColor = isDefault
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.6);
|
||||
return Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: _inlineAddEdgeOffset,
|
||||
top: index == 0 ? _inlineAddEdgeOffset : 0,
|
||||
bottom: _inlineAddEdgeOffset,
|
||||
),
|
||||
child: ClipPath(
|
||||
clipper: _GearEditorNotchClipper(
|
||||
topNotchRadius: _inlineAddRadius + 2,
|
||||
bottomNotchRadius: _inlineAddRadius + 2,
|
||||
),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _setDefaultGear(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: isDefault ? 1.6 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Gear ${index + 1}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isDefault)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(
|
||||
alpha: 0.65,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'default',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Delete gear',
|
||||
onPressed: () => _deleteGear(index),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => _editRatioText(index),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(
|
||||
ratio.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: sliderValue,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_draft[index] =
|
||||
_quantizeRatio(_sliderToValue(value));
|
||||
});
|
||||
},
|
||||
onChangeEnd: (_) {
|
||||
setState(() {
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index == 0)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: _buildInlineAddButton(
|
||||
context,
|
||||
afterIndex: index - 1,
|
||||
canAdd: canAdd,
|
||||
tooltip: 'Insert gear before',
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: _buildInlineAddButton(
|
||||
context,
|
||||
afterIndex: index,
|
||||
canAdd: canAdd,
|
||||
tooltip: 'Insert gear after',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _insertGearAfter(int index) {
|
||||
if (_draft.length >= _maxGears) {
|
||||
return;
|
||||
}
|
||||
final insertIndex = (index + 1).clamp(0, _draft.length);
|
||||
final newRatio = _nextInsertedRatio(insertIndex);
|
||||
|
||||
setState(() {
|
||||
final next = List<double>.from(_draft)..insert(insertIndex, newRatio);
|
||||
_draft = next;
|
||||
if (insertIndex <= _draftDefaultGearIndex) {
|
||||
_draftDefaultGearIndex += 1;
|
||||
}
|
||||
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||
_draftDefaultGearIndex,
|
||||
_draft.length,
|
||||
);
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
} else {
|
||||
_gearLayoutVersion++;
|
||||
}
|
||||
_stretchBase = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _deleteGear(int index) {
|
||||
if (_draft.isEmpty || index < 0 || index >= _draft.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
final next = List<double>.from(_draft)..removeAt(index);
|
||||
_draft = next;
|
||||
|
||||
if (_draft.isEmpty) {
|
||||
_draftDefaultGearIndex = 0;
|
||||
} else if (index < _draftDefaultGearIndex) {
|
||||
_draftDefaultGearIndex -= 1;
|
||||
} else if (index == _draftDefaultGearIndex) {
|
||||
_draftDefaultGearIndex = index.clamp(0, _draft.length - 1);
|
||||
}
|
||||
|
||||
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||
_draftDefaultGearIndex,
|
||||
_draft.length,
|
||||
);
|
||||
_gearLayoutVersion++;
|
||||
_stretchBase = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _setDefaultGear(int index) {
|
||||
if (index < 0 || index >= _draft.length) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_draftDefaultGearIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
double _nextInsertedRatio(int insertIndex) {
|
||||
if (_draft.isEmpty) {
|
||||
return _quantizeRatio(1.0);
|
||||
}
|
||||
if (insertIndex <= 0) {
|
||||
return _quantizeRatio(_draft.first * 0.9);
|
||||
}
|
||||
if (insertIndex >= _draft.length) {
|
||||
return _quantizeRatio(_draft.last * 1.1);
|
||||
}
|
||||
return _quantizeRatio((_draft[insertIndex - 1] + _draft[insertIndex]) / 2);
|
||||
}
|
||||
|
||||
Future<void> _editRatioText(int index) async {
|
||||
final controller = TextEditingController(
|
||||
text: _draft[index].toStringAsFixed(2),
|
||||
@ -493,6 +751,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
|
||||
setState(() {
|
||||
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
|
||||
_draftDefaultGearIndex = _normalizeDefaultIndex(0, _draft.length);
|
||||
if (_sortAscending) {
|
||||
_sortDraft(animate: true);
|
||||
}
|
||||
@ -500,11 +759,14 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
}
|
||||
|
||||
void _sortDraft({bool animate = false}) {
|
||||
final sorted = _sorted(_draft);
|
||||
if (animate && !_listEquals(_draft, sorted)) {
|
||||
final sorted = _sortedWithDefault(_draft, _draftDefaultGearIndex);
|
||||
final sortedValues = sorted.$1;
|
||||
final sortedDefaultIndex = sorted.$2;
|
||||
if (animate && !_listEquals(_draft, sortedValues)) {
|
||||
_gearLayoutVersion++;
|
||||
}
|
||||
_draft = sorted;
|
||||
_draft = sortedValues;
|
||||
_draftDefaultGearIndex = sortedDefaultIndex;
|
||||
}
|
||||
|
||||
void _enterEditMode() {
|
||||
@ -514,6 +776,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
_stretchFactor = 1.0;
|
||||
_stretchBase = null;
|
||||
_draft = List<double>.from(_committed);
|
||||
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||
_committedDefaultGearIndex,
|
||||
_draft.length,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -521,6 +787,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
setState(() {
|
||||
_isEditing = false;
|
||||
_draft = List<double>.from(_committed);
|
||||
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||
_committedDefaultGearIndex,
|
||||
_draft.length,
|
||||
);
|
||||
_stretchFactor = 1.0;
|
||||
_stretchBase = null;
|
||||
});
|
||||
@ -531,7 +801,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
final message = await widget.onSave(List<double>.from(_draft));
|
||||
final message = await widget.onSave(
|
||||
List<double>.from(_draft),
|
||||
_normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@ -540,6 +813,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
_isSaving = false;
|
||||
if (message == null) {
|
||||
_committed = List<double>.from(_draft);
|
||||
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||
_draftDefaultGearIndex,
|
||||
_committed.length,
|
||||
);
|
||||
_isEditing = false;
|
||||
}
|
||||
});
|
||||
@ -551,7 +828,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _ratioChip(BuildContext context, int gear, double ratio) {
|
||||
Widget _ratioChip(
|
||||
BuildContext context,
|
||||
int gear,
|
||||
double ratio, {
|
||||
bool isDefault = false,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
@ -559,10 +841,17 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
color: isDefault
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
width: isDefault ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
child: Text('G$gear ${ratio.toStringAsFixed(2)}'),
|
||||
child: Text(
|
||||
isDefault
|
||||
? 'G$gear ${ratio.toStringAsFixed(2)} default'
|
||||
: 'G$gear ${ratio.toStringAsFixed(2)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -570,6 +859,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
BuildContext context,
|
||||
List<double> ratios, {
|
||||
bool showGearLabel = true,
|
||||
int? defaultGearIndex,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
@ -588,13 +878,18 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
color: i == defaultGearIndex
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
width: i == defaultGearIndex ? 1.3 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
showGearLabel
|
||||
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}'
|
||||
? (i == defaultGearIndex
|
||||
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)} default'
|
||||
: 'G${i + 1} ${ratios[i].toStringAsFixed(2)}')
|
||||
: ratios[i].toStringAsFixed(2),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -613,9 +908,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
|
||||
}
|
||||
|
||||
List<double> _sorted(List<double> values) {
|
||||
final out = List<double>.from(values)..sort();
|
||||
return out;
|
||||
(List<double>, int) _sortedWithDefault(
|
||||
List<double> values, int defaultIndex) {
|
||||
final normalizedDefault =
|
||||
_normalizeDefaultIndex(defaultIndex, values.length);
|
||||
final decorated = List<_DraftGearEntry>.generate(
|
||||
values.length,
|
||||
(i) => _DraftGearEntry(
|
||||
ratio: values[i],
|
||||
isDefault: i == normalizedDefault,
|
||||
),
|
||||
growable: false,
|
||||
)..sort((a, b) => a.ratio.compareTo(b.ratio));
|
||||
|
||||
final sortedValues = decorated.map((entry) => entry.ratio).toList(
|
||||
growable: false,
|
||||
);
|
||||
final sortedDefaultIndex = decorated.indexWhere((entry) => entry.isDefault);
|
||||
return (
|
||||
sortedValues,
|
||||
_normalizeDefaultIndex(sortedDefaultIndex, sortedValues.length),
|
||||
);
|
||||
}
|
||||
|
||||
int _normalizeDefaultIndex(int index, int length) {
|
||||
if (length <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return index.clamp(0, length - 1).toInt();
|
||||
}
|
||||
|
||||
bool _listEquals(List<double> a, List<double> b) {
|
||||
@ -661,6 +981,69 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
}
|
||||
}
|
||||
|
||||
class _DraftGearEntry {
|
||||
const _DraftGearEntry({required this.ratio, required this.isDefault});
|
||||
|
||||
final double ratio;
|
||||
final bool isDefault;
|
||||
}
|
||||
|
||||
class _GearEditorNotchClipper extends CustomClipper<Path> {
|
||||
const _GearEditorNotchClipper({
|
||||
required this.topNotchRadius,
|
||||
required this.bottomNotchRadius,
|
||||
});
|
||||
|
||||
final double topNotchRadius;
|
||||
final double bottomNotchRadius;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final base = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Offset.zero & size,
|
||||
const Radius.circular(12),
|
||||
),
|
||||
);
|
||||
|
||||
if (topNotchRadius <= 0 && bottomNotchRadius <= 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
var clipped = base;
|
||||
if (topNotchRadius > 0) {
|
||||
final topNotch = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(size.width, 0),
|
||||
radius: topNotchRadius,
|
||||
),
|
||||
);
|
||||
clipped = Path.combine(PathOperation.difference, clipped, topNotch);
|
||||
}
|
||||
|
||||
if (bottomNotchRadius > 0) {
|
||||
final bottomNotch = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(size.width, size.height),
|
||||
radius: bottomNotchRadius,
|
||||
),
|
||||
);
|
||||
clipped = Path.combine(PathOperation.difference, clipped, bottomNotch);
|
||||
}
|
||||
|
||||
return clipped;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant _GearEditorNotchClipper oldClipper) {
|
||||
return oldClipper.topNotchRadius != topNotchRadius ||
|
||||
oldClipper.bottomNotchRadius != bottomNotchRadius;
|
||||
}
|
||||
}
|
||||
|
||||
class _GearRatioGraph extends StatelessWidget {
|
||||
const _GearRatioGraph({required this.ratios, required this.compact});
|
||||
|
||||
@ -681,9 +1064,12 @@ class _GearRatioGraph extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!compact)
|
||||
Text(
|
||||
'Input RPM -> Output RPM',
|
||||
style: textTheme.bodySmall,
|
||||
Container(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
'Input RPM -> Output RPM',
|
||||
style: textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CustomPaint(
|
||||
|
||||
@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import file_picker
|
||||
import flutter_blue_plus_darwin
|
||||
import nb_utils
|
||||
import path_provider_foundation
|
||||
@ -15,6 +16,7 @@ import sqlite3_flutter_libs
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
|
||||
32
pubspec.lock
32
pubspec.lock
@ -233,6 +233,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -345,6 +353,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -427,6 +443,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_reactive_ble:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1187,6 +1211,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.13.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -54,6 +54,7 @@ dependencies:
|
||||
flutter_reactive_ble: ^5.4.0
|
||||
nb_utils: ^7.2.0
|
||||
cbor: ^6.3.3
|
||||
file_picker: ^8.1.7
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
137
test/service/dfu_preflight_test.dart
Normal file
137
test/service/dfu_preflight_test.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ShifterService.runDfuPreflight', () {
|
||||
test('fails when no active button connection exists', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.disconnected, null),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason,
|
||||
DfuPreflightFailureReason.deviceNotConnected);
|
||||
expect(adapter.requestMtuCallCount, 0);
|
||||
});
|
||||
|
||||
test('fails when connected to a different button', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'wrong-device'),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason,
|
||||
DfuPreflightFailureReason.wrongConnectedDevice);
|
||||
expect(adapter.requestMtuCallCount, 0);
|
||||
});
|
||||
|
||||
test('fails when MTU negotiation fails', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: bail('adapter rejected mtu request'),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight(requestedMtu: 247);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(
|
||||
preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed);
|
||||
expect(preflight.message, contains('adapter rejected mtu request'));
|
||||
expect(adapter.requestedMtuValues, [247]);
|
||||
});
|
||||
|
||||
test('fails when negotiated MTU is too low for 64-byte frame writes',
|
||||
() async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: Ok(universalShifterDfuMinimumMtu - 1),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow);
|
||||
expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1);
|
||||
expect(preflight.requiredMtu, universalShifterDfuMinimumMtu);
|
||||
});
|
||||
|
||||
test('passes when connected to target and MTU is sufficient', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isTrue);
|
||||
expect(preflight.failureReason, isNull);
|
||||
expect(preflight.negotiatedMtu, 128);
|
||||
expect(preflight.requestedMtu, universalShifterDfuPreferredMtu);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeDfuPreflightBluetoothAdapter
|
||||
implements DfuPreflightBluetoothAdapter {
|
||||
_FakeDfuPreflightBluetoothAdapter({
|
||||
required this.currentConnectionState,
|
||||
required Result<int> mtuResult,
|
||||
}) : _mtuResult = mtuResult;
|
||||
|
||||
@override
|
||||
final (ConnectionStatus, String?) currentConnectionState;
|
||||
|
||||
final Result<int> _mtuResult;
|
||||
|
||||
int requestMtuCallCount = 0;
|
||||
final List<int> requestedMtuValues = <int>[];
|
||||
|
||||
@override
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
}) async {
|
||||
requestMtuCallCount += 1;
|
||||
requestedMtuValues.add(mtu);
|
||||
return _mtuResult;
|
||||
}
|
||||
}
|
||||
86
test/service/dfu_protocol_test.dart
Normal file
86
test/service/dfu_protocol_test.dart
Normal file
@ -0,0 +1,86 @@
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('DfuProtocol CRC32', () {
|
||||
test('matches known vector', () {
|
||||
final crc = DfuProtocol.crc32('123456789'.codeUnits);
|
||||
expect(crc, 0xCBF43926);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol control payload encoding', () {
|
||||
test('encodes START payload with exact 11-byte LE layout', () {
|
||||
final payload = DfuProtocol.encodeStartPayload(
|
||||
const DfuStartPayload(
|
||||
totalLength: 0x1234,
|
||||
imageCrc32: 0x89ABCDEF,
|
||||
sessionId: 0x22,
|
||||
flags: universalShifterDfuFlagEncrypted,
|
||||
),
|
||||
);
|
||||
|
||||
expect(payload.length, 11);
|
||||
expect(
|
||||
payload,
|
||||
[
|
||||
universalShifterDfuOpcodeStart,
|
||||
0x34,
|
||||
0x12,
|
||||
0x00,
|
||||
0x00,
|
||||
0xEF,
|
||||
0xCD,
|
||||
0xAB,
|
||||
0x89,
|
||||
0x22,
|
||||
universalShifterDfuFlagEncrypted,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes FINISH and ABORT payloads as one byte', () {
|
||||
expect(
|
||||
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
|
||||
expect(
|
||||
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol data frame building', () {
|
||||
test('builds 64-byte frames and handles final partial payload', () {
|
||||
final image = List<int>.generate(80, (index) => index);
|
||||
final frames = DfuProtocol.buildDataFrames(image);
|
||||
|
||||
expect(frames.length, 2);
|
||||
|
||||
expect(frames[0].sequence, 0);
|
||||
expect(frames[0].offset, 0);
|
||||
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
|
||||
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
|
||||
|
||||
expect(frames[1].sequence, 1);
|
||||
expect(frames[1].offset, 63);
|
||||
expect(frames[1].payloadLength, 17);
|
||||
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol sequence and ACK helpers', () {
|
||||
test('wraps sequence values and computes ack+1 rewind', () {
|
||||
expect(DfuProtocol.nextSequence(0x00), 0x01);
|
||||
expect(DfuProtocol.nextSequence(0xFF), 0x00);
|
||||
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
|
||||
});
|
||||
|
||||
test('computes wrapping sequence distance', () {
|
||||
expect(DfuProtocol.sequenceDistance(250, 2), 8);
|
||||
expect(DfuProtocol.sequenceDistance(1, 1), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
132
test/service/firmware_file_selection_service_test.dart
Normal file
132
test/service/firmware_file_selection_service_test.dart
Normal file
@ -0,0 +1,132 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareFileSelectionService', () {
|
||||
test('prepares v1 metadata for selected .bin firmware', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.BIN',
|
||||
filePath: '/tmp/firmware.BIN',
|
||||
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => 0x1AB,
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
expect(result.isSuccess, isTrue);
|
||||
|
||||
final firmware = result.firmware!;
|
||||
expect(firmware.fileName, 'firmware.BIN');
|
||||
expect(firmware.filePath, '/tmp/firmware.BIN');
|
||||
expect(firmware.fileBytes, <int>[1, 2, 3, 4]);
|
||||
expect(firmware.metadata.totalLength, 4);
|
||||
expect(firmware.metadata.crc32, 0xB63CFBCD);
|
||||
expect(firmware.metadata.sessionId, 0xAB);
|
||||
expect(firmware.metadata.flags, universalShifterDfuFlagNone);
|
||||
});
|
||||
|
||||
test('returns canceled result when user dismisses picker', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(selection: null),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.isCanceled, isTrue);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.canceled);
|
||||
});
|
||||
|
||||
test('rejects unsupported extension', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.hex',
|
||||
fileBytes: Uint8List.fromList(<int>[1]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason,
|
||||
FirmwareSelectionFailureReason.unsupportedExtension);
|
||||
});
|
||||
|
||||
test('rejects empty payload', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List(0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile);
|
||||
});
|
||||
|
||||
test('generates session id per run', () async {
|
||||
var nextSession = 9;
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List.fromList(<int>[10]),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => nextSession++,
|
||||
);
|
||||
|
||||
final first = await service.selectAndPrepareDfuV1();
|
||||
final second = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(first.firmware?.metadata.sessionId, 9);
|
||||
expect(second.firmware?.metadata.sessionId, 10);
|
||||
});
|
||||
|
||||
test('maps picker read failure to explicit validation error', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: null,
|
||||
error: const FormatException('broken pick payload'),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed);
|
||||
expect(result.failure?.message, contains('broken pick payload'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareFilePicker implements FirmwareFilePicker {
|
||||
_FakeFirmwareFilePicker({
|
||||
required this.selection,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final FirmwarePickerSelection? selection;
|
||||
final Object? error;
|
||||
|
||||
@override
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile() async {
|
||||
if (error != null) {
|
||||
throw error!;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
350
test/service/firmware_update_service_test.dart
Normal file
350
test/service/firmware_update_service_test.dart
Normal file
@ -0,0 +1,350 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareUpdateService', () {
|
||||
test('completes happy path with START, data frames, and FINISH', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport();
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 7,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.controlWrites.length, 2);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 3,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
maxNoProgressRetries: 4,
|
||||
);
|
||||
|
||||
final image = List<int>.generate(190, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 9,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.dataWrites.length, greaterThan(4));
|
||||
expect(transport.sequenceWriteCount(1), greaterThan(1));
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel sends ABORT and reports aborted state', () async {
|
||||
final firstFrameSent = Completer<void>();
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
onDataWrite: (frame) {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
suppressDataAcks: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
||||
sessionId: 11,
|
||||
);
|
||||
|
||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||
await service.cancelUpdate();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when reconnect does not succeed after expected reset',
|
||||
() async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
reconnectError: 'simulated reconnect timeout',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 13,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('did not reconnect'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when expected reset disconnect is not observed', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
resetDisconnectError: 'simulated missing disconnect',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 15,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('expected post-FINISH reset disconnect'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
['waitForExpectedResetDisconnect'],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when post-update status verification read fails', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
verificationError: 'simulated status read failure',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 14,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('post-update verification failed'),
|
||||
);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('does not expose a version characteristic'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
this.dropFirstSequence,
|
||||
this.onDataWrite,
|
||||
this.suppressDataAcks = false,
|
||||
this.resetDisconnectError,
|
||||
this.reconnectError,
|
||||
this.verificationError,
|
||||
});
|
||||
|
||||
final int? dropFirstSequence;
|
||||
final void Function(List<int> frame)? onDataWrite;
|
||||
final bool suppressDataAcks;
|
||||
final String? resetDisconnectError;
|
||||
final String? reconnectError;
|
||||
final String? verificationError;
|
||||
|
||||
final StreamController<List<int>> _ackController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
|
||||
final List<List<int>> controlWrites = <List<int>>[];
|
||||
final List<List<int>> dataWrites = <List<int>>[];
|
||||
final List<String> postFinishSteps = <String>[];
|
||||
final Set<int> _droppedOnce = <int>{};
|
||||
int _lastAck = 0xFF;
|
||||
int _expectedSequence = 0;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) async {
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: 128,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) async {
|
||||
controlWrites.add(List<int>.from(payload, growable: false));
|
||||
|
||||
final opcode = payload.isEmpty ? -1 : payload.first;
|
||||
if (opcode == universalShifterDfuOpcodeStart) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([0xFF]);
|
||||
});
|
||||
}
|
||||
|
||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
||||
dataWrites.add(List<int>.from(frame, growable: false));
|
||||
onDataWrite?.call(frame);
|
||||
|
||||
if (suppressDataAcks) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
final sequence = frame.first;
|
||||
final shouldDrop = dropFirstSequence != null &&
|
||||
sequence == dropFirstSequence &&
|
||||
!_droppedOnce.contains(sequence);
|
||||
|
||||
if (shouldDrop) {
|
||||
_droppedOnce.add(sequence);
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
if (sequence == _expectedSequence) {
|
||||
_lastAck = sequence;
|
||||
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
||||
}
|
||||
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('waitForExpectedResetDisconnect');
|
||||
if (resetDisconnectError != null) {
|
||||
return bail(resetDisconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('reconnectForVerification');
|
||||
if (reconnectError != null) {
|
||||
return bail(reconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('verifyDeviceReachable');
|
||||
if (verificationError != null) {
|
||||
return bail(verificationError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
int sequenceWriteCount(int sequence) {
|
||||
var count = 0;
|
||||
for (final frame in dataWrites) {
|
||||
if (frame.first == sequence) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _ackController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user