Compare commits

...

14 Commits

38 changed files with 3017 additions and 51 deletions

49
.beads/.gitignore vendored Normal file
View 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
View 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*

View 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
}
}

View File

View 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"}

View 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"}

View 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":""}

View 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":""}

View File

55
.beads/config.yaml Normal file
View 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
View File

@ -0,0 +1 @@
48179

View File

@ -0,0 +1 @@
1772550918

1
.beads/dolt-server.port Normal file
View File

@ -0,0 +1 @@
13365

9
.beads/hooks/post-checkout Executable file
View 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
View 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
View 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
View 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 ---

View 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 ---

View File

6
.beads/metadata.json Normal file
View File

@ -0,0 +1,6 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "server",
"dolt_database": "abawo_bt_app"
}

4
.gitignore vendored
View File

@ -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
View 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

View File

@ -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');
}

View 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);
}
}

View File

@ -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,

View File

@ -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';
@ -64,9 +67,46 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
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,
@ -88,6 +128,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
_shifterService?.dispose();
_firmwareProgressSubscription?.cancel();
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
super.dispose();
}
@ -100,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();
@ -127,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();
}
}
@ -161,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());
}
@ -216,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;
}
@ -257,6 +324,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
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.';
@ -289,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,
@ -328,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();
@ -465,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,
@ -505,31 +763,57 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
},
),
const SizedBox(height: 16),
GearRatioEditorCard(
ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
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,
),
],
),
),
),
],
],
),
@ -583,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,

View 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;
}
}

View 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);
}
}

View 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();
}

View File

@ -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();
@ -28,7 +46,7 @@ class ShifterService {
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
return _bluetooth.writeCharacteristic(
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterConnectToAddrCharacteristicUuid,
@ -42,7 +60,7 @@ class ShifterService {
}
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _bluetooth.writeCharacteristic(
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid,
@ -59,7 +77,7 @@ class ShifterService {
}
Future<Result<GearRatiosData>> readGearRatios() async {
final readRes = await _bluetooth.readCharacteristic(
final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
@ -106,8 +124,10 @@ class ShifterService {
}
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
final mtuResult =
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
final mtuResult = await _requireBluetooth.requestMtu(
buttonDeviceId,
mtu: _gearRatioWriteMtu,
);
if (mtuResult.isErr()) {
return bail(
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
@ -124,7 +144,7 @@ class ShifterService {
payload[_defaultGearIndexOffset] =
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
return _bluetooth.writeCharacteristic(
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
@ -133,7 +153,7 @@ class ShifterService {
}
Future<Result<CentralStatus>> readStatus() async {
final readRes = await _bluetooth.readCharacteristic(
final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
@ -149,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,
@ -202,6 +288,32 @@ class ShifterService {
}
}
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,

View File

@ -52,8 +52,6 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
double get _inlineAddEdgeOffset => _inlineAddRadius + 2;
double get _editorTilesTopInset => _inlineAddEdgeOffset + _inlineAddRadius;
bool _isExpanded = false;
bool _isEditing = false;
bool _sortAscending = true;
@ -196,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,
@ -265,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),
@ -307,21 +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: Padding(
padding: EdgeInsets.only(
top: _editorTilesTopInset,
),
child: Column(
key: ValueKey('editors-$_gearLayoutVersion'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildEditableGearTiles(context),
),
child: Column(
key: ValueKey('editors-$_gearLayoutVersion'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _buildEditableGearTiles(context),
),
),
),
@ -1071,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(

View File

@ -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"))

View File

@ -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:

View File

@ -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:

View 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;
}
}

View 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);
});
});
}

View 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;
}
}

View 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();
}
}