Compare commits
10 Commits
08405c879b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac68e09ab | |||
| 1dbbf191e6 | |||
| 32f258a492 | |||
| c581b4d92c | |||
| aafa9928ac | |||
| 8b24084f97 | |||
| dd2afa34ef | |||
| fb85565854 | |||
| 7a33e71410 | |||
| e704f27a96 |
@ -1,12 +1,12 @@
|
||||
{
|
||||
"last_dolt_commit": "6ork55emq2c5smq0bgf8nj7g2bssqsja",
|
||||
"last_dolt_commit": "mf3is3p9ve79on2q1achdjp8v50rq6s3",
|
||||
"last_event_id": 0,
|
||||
"timestamp": "2026-03-03T15:37:50.206534932Z",
|
||||
"timestamp": "2026-03-04T17:07:18.317614374Z",
|
||||
"counts": {
|
||||
"issues": 1,
|
||||
"events": 1,
|
||||
"issues": 10,
|
||||
"events": 24,
|
||||
"comments": 0,
|
||||
"dependencies": 0,
|
||||
"dependencies": 21,
|
||||
"labels": 0,
|
||||
"config": 11
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -1 +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":""}
|
||||
|
||||
@ -1 +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":""}
|
||||
|
||||
@ -299,11 +299,20 @@ class BluetoothController {
|
||||
|
||||
Future<Result<void>> requestMtu(String deviceId,
|
||||
{int mtu = defaultMtu}) async {
|
||||
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||
if (result.isErr()) {
|
||||
return bail(result.unwrapErr());
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
Future<Result<int>> requestMtuAndGetValue(String deviceId,
|
||||
{int mtu = defaultMtu}) async {
|
||||
try {
|
||||
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
||||
log.info(
|
||||
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
||||
return Ok(null);
|
||||
return Ok(negotiatedMtu);
|
||||
} catch (e) {
|
||||
return bail('Error requesting MTU $mtu for $deviceId: $e');
|
||||
}
|
||||
|
||||
70
lib/model/firmware_file_selection.dart
Normal file
70
lib/model/firmware_file_selection.dart
Normal file
@ -0,0 +1,70 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class DfuV1FirmwareMetadata {
|
||||
const DfuV1FirmwareMetadata({
|
||||
required this.totalLength,
|
||||
required this.crc32,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int crc32;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuV1PreparedFirmware {
|
||||
const DfuV1PreparedFirmware({
|
||||
required this.fileName,
|
||||
required this.fileBytes,
|
||||
required this.metadata,
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final String? filePath;
|
||||
final Uint8List fileBytes;
|
||||
final DfuV1FirmwareMetadata metadata;
|
||||
}
|
||||
|
||||
enum FirmwareSelectionFailureReason {
|
||||
canceled,
|
||||
malformedSelection,
|
||||
unsupportedExtension,
|
||||
emptyFile,
|
||||
readFailed,
|
||||
}
|
||||
|
||||
class FirmwareSelectionFailure {
|
||||
const FirmwareSelectionFailure({
|
||||
required this.reason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final FirmwareSelectionFailureReason reason;
|
||||
final String message;
|
||||
}
|
||||
|
||||
class FirmwareFileSelectionResult {
|
||||
const FirmwareFileSelectionResult._({
|
||||
this.firmware,
|
||||
this.failure,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? firmware;
|
||||
final FirmwareSelectionFailure? failure;
|
||||
|
||||
bool get isSuccess => firmware != null;
|
||||
|
||||
bool get isCanceled =>
|
||||
failure?.reason == FirmwareSelectionFailureReason.canceled;
|
||||
|
||||
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) {
|
||||
return FirmwareFileSelectionResult._(firmware: firmware);
|
||||
}
|
||||
|
||||
static FirmwareFileSelectionResult failed(FirmwareSelectionFailure failure) {
|
||||
return FirmwareFileSelectionResult._(failure: failure);
|
||||
}
|
||||
}
|
||||
@ -10,14 +10,163 @@ const String universalShifterCommandCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
||||
const String universalShifterGearRatiosCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40006';
|
||||
const String universalShifterDfuControlCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40008';
|
||||
const String universalShifterDfuDataCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba40009';
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
const int universalShifterDfuOpcodeStart = 0x01;
|
||||
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||
const int universalShifterDfuOpcodeAbort = 0x03;
|
||||
|
||||
const int universalShifterDfuFrameSizeBytes = 64;
|
||||
const int universalShifterDfuFramePayloadSizeBytes = 63;
|
||||
const int universalShifterAttWriteOverheadBytes = 3;
|
||||
const int universalShifterDfuMinimumMtu =
|
||||
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
||||
const int universalShifterDfuPreferredMtu = 128;
|
||||
|
||||
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||
const int universalShifterDfuFlagSigned = 0x02;
|
||||
const int universalShifterDfuFlagNone = 0x00;
|
||||
|
||||
const int errorSequence = 1;
|
||||
const int errorFtmsMissing = 2;
|
||||
const int errorPairingAuth = 3;
|
||||
const int errorPairingEncrypt = 4;
|
||||
const int errorFtmsRequiredCharMissing = 5;
|
||||
|
||||
enum DfuUpdateState {
|
||||
idle,
|
||||
starting,
|
||||
waitingForAck,
|
||||
transferring,
|
||||
finishing,
|
||||
completed,
|
||||
aborted,
|
||||
failed,
|
||||
}
|
||||
|
||||
class DfuUpdateFlags {
|
||||
const DfuUpdateFlags({
|
||||
this.encrypted = false,
|
||||
this.signed = false,
|
||||
});
|
||||
|
||||
final bool encrypted;
|
||||
final bool signed;
|
||||
|
||||
int get rawValue {
|
||||
var value = 0;
|
||||
if (encrypted) {
|
||||
value |= universalShifterDfuFlagEncrypted;
|
||||
}
|
||||
if (signed) {
|
||||
value |= universalShifterDfuFlagSigned;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static DfuUpdateFlags fromRaw(int rawValue) {
|
||||
return DfuUpdateFlags(
|
||||
encrypted: (rawValue & universalShifterDfuFlagEncrypted) != 0,
|
||||
signed: (rawValue & universalShifterDfuFlagSigned) != 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DfuUpdateProgress {
|
||||
const DfuUpdateProgress({
|
||||
required this.state,
|
||||
required this.totalBytes,
|
||||
required this.sentBytes,
|
||||
required this.lastAckedSequence,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final DfuUpdateState state;
|
||||
final int totalBytes;
|
||||
final int sentBytes;
|
||||
final int lastAckedSequence;
|
||||
final int sessionId;
|
||||
final DfuUpdateFlags flags;
|
||||
final String? errorMessage;
|
||||
|
||||
double get fractionComplete {
|
||||
if (totalBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return sentBytes.clamp(0, totalBytes) / totalBytes;
|
||||
}
|
||||
|
||||
int get percentComplete => (fractionComplete * 100).round();
|
||||
|
||||
bool get isTerminal =>
|
||||
state == DfuUpdateState.completed ||
|
||||
state == DfuUpdateState.aborted ||
|
||||
state == DfuUpdateState.failed;
|
||||
}
|
||||
|
||||
enum DfuPreflightFailureReason {
|
||||
deviceNotConnected,
|
||||
wrongConnectedDevice,
|
||||
mtuRequestFailed,
|
||||
mtuTooLow,
|
||||
}
|
||||
|
||||
class DfuPreflightResult {
|
||||
const DfuPreflightResult._({
|
||||
required this.requestedMtu,
|
||||
required this.requiredMtu,
|
||||
required this.negotiatedMtu,
|
||||
required this.failureReason,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
final int requestedMtu;
|
||||
final int requiredMtu;
|
||||
final int? negotiatedMtu;
|
||||
final DfuPreflightFailureReason? failureReason;
|
||||
final String? message;
|
||||
|
||||
bool get canStart => failureReason == null;
|
||||
|
||||
static DfuPreflightResult ready({
|
||||
required int requestedMtu,
|
||||
required int negotiatedMtu,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: null,
|
||||
message: null,
|
||||
);
|
||||
}
|
||||
|
||||
static DfuPreflightResult failed({
|
||||
required int requestedMtu,
|
||||
required DfuPreflightFailureReason failureReason,
|
||||
required String message,
|
||||
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||
int? negotiatedMtu,
|
||||
}) {
|
||||
return DfuPreflightResult._(
|
||||
requestedMtu: requestedMtu,
|
||||
requiredMtu: requiredMtu,
|
||||
negotiatedMtu: negotiatedMtu,
|
||||
failureReason: failureReason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShifterErrorInfo {
|
||||
const ShifterErrorInfo({
|
||||
required this.code,
|
||||
|
||||
@ -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,
|
||||
@ -508,18 +766,42 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
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),
|
||||
GearRatioEditorCard(
|
||||
_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: _loadGearRatios,
|
||||
onRetry:
|
||||
_isFirmwareUpdateBusy ? null : _loadGearRatios,
|
||||
onSave: _saveGearRatios,
|
||||
presets: const [
|
||||
GearRatioPreset(
|
||||
@ -530,6 +812,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -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,
|
||||
|
||||
137
lib/service/dfu_protocol.dart
Normal file
137
lib/service/dfu_protocol.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
|
||||
const int _startPayloadLength = 11;
|
||||
|
||||
class DfuStartPayload {
|
||||
const DfuStartPayload({
|
||||
required this.totalLength,
|
||||
required this.imageCrc32,
|
||||
required this.sessionId,
|
||||
required this.flags,
|
||||
});
|
||||
|
||||
final int totalLength;
|
||||
final int imageCrc32;
|
||||
final int sessionId;
|
||||
final int flags;
|
||||
}
|
||||
|
||||
class DfuDataFrame {
|
||||
const DfuDataFrame({
|
||||
required this.sequence,
|
||||
required this.offset,
|
||||
required this.payloadLength,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final int sequence;
|
||||
final int offset;
|
||||
final int payloadLength;
|
||||
final Uint8List bytes;
|
||||
}
|
||||
|
||||
class DfuProtocol {
|
||||
const DfuProtocol._();
|
||||
|
||||
static Uint8List encodeStartPayload(DfuStartPayload payload) {
|
||||
final data = ByteData(_startPayloadLength);
|
||||
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||
data.setUint32(1, payload.totalLength, Endian.little);
|
||||
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||
data.setUint8(9, payload.sessionId);
|
||||
data.setUint8(10, payload.flags);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
static Uint8List encodeFinishPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
|
||||
}
|
||||
|
||||
static Uint8List encodeAbortPayload() {
|
||||
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
|
||||
}
|
||||
|
||||
static List<DfuDataFrame> buildDataFrames(
|
||||
List<int> imageBytes, {
|
||||
int startSequence = 0,
|
||||
}) {
|
||||
final frames = <DfuDataFrame>[];
|
||||
var seq = _asU8(startSequence);
|
||||
var offset = 0;
|
||||
while (offset < imageBytes.length) {
|
||||
final remaining = imageBytes.length - offset;
|
||||
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
||||
? remaining
|
||||
: universalShifterDfuFramePayloadSizeBytes;
|
||||
|
||||
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
||||
frame[0] = seq;
|
||||
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
||||
|
||||
frames.add(
|
||||
DfuDataFrame(
|
||||
sequence: seq,
|
||||
offset: offset,
|
||||
payloadLength: chunkLength,
|
||||
bytes: frame,
|
||||
),
|
||||
);
|
||||
|
||||
offset += chunkLength;
|
||||
seq = nextSequence(seq);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
static int nextSequence(int sequence) {
|
||||
return _asU8(sequence + 1);
|
||||
}
|
||||
|
||||
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
||||
return nextSequence(acknowledgedSequence);
|
||||
}
|
||||
|
||||
static int sequenceDistance(int from, int to) {
|
||||
return _asU8(to - from);
|
||||
}
|
||||
|
||||
static int parseAckPayload(List<int> payload) {
|
||||
if (payload.length != 1) {
|
||||
throw const FormatException('ACK payload must be exactly 1 byte.');
|
||||
}
|
||||
return _asU8(payload.first);
|
||||
}
|
||||
|
||||
static const int crc32Initial = 0xFFFFFFFF;
|
||||
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||
|
||||
static int crc32Update(int crc, List<int> bytes) {
|
||||
var next = crc & 0xFFFFFFFF;
|
||||
for (final byte in bytes) {
|
||||
next ^= byte;
|
||||
for (var bit = 0; bit < 8; bit++) {
|
||||
if ((next & 0x1) != 0) {
|
||||
next = (next >> 1) ^ _crc32PolynomialReflected;
|
||||
} else {
|
||||
next >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32Finalize(int crc) {
|
||||
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
static int crc32(List<int> bytes) {
|
||||
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||
}
|
||||
|
||||
static int _asU8(int value) {
|
||||
return value & 0xFF;
|
||||
}
|
||||
}
|
||||
154
lib/service/firmware_file_selection_service.dart
Normal file
154
lib/service/firmware_file_selection_service.dart
Normal file
@ -0,0 +1,154 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
typedef SessionIdGenerator = int Function();
|
||||
|
||||
class FirmwarePickerSelection {
|
||||
const FirmwarePickerSelection({
|
||||
required this.fileName,
|
||||
required this.fileBytes,
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final Uint8List fileBytes;
|
||||
final String? filePath;
|
||||
}
|
||||
|
||||
abstract interface class FirmwareFilePicker {
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile();
|
||||
}
|
||||
|
||||
class LocalFirmwareFilePicker implements FirmwareFilePicker {
|
||||
@override
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile() async {
|
||||
final pickResult = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: false,
|
||||
withData: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['bin'],
|
||||
);
|
||||
if (pickResult == null) {
|
||||
return null;
|
||||
}
|
||||
if (pickResult.files.isEmpty) {
|
||||
return FirmwarePickerSelection(
|
||||
fileName: '',
|
||||
fileBytes: Uint8List(0),
|
||||
);
|
||||
}
|
||||
|
||||
final selected = pickResult.files.first;
|
||||
final bytes = selected.bytes ?? await _readFromPath(selected.path);
|
||||
|
||||
return FirmwarePickerSelection(
|
||||
fileName: selected.name,
|
||||
filePath: selected.path,
|
||||
fileBytes: bytes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _readFromPath(String? path) async {
|
||||
if (path == null || path.trim().isEmpty) {
|
||||
throw const FileSystemException(
|
||||
'Selected file did not contain readable bytes or a valid path.',
|
||||
);
|
||||
}
|
||||
final file = File(path);
|
||||
return file.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
class FirmwareFileSelectionService {
|
||||
FirmwareFileSelectionService({
|
||||
required FirmwareFilePicker filePicker,
|
||||
SessionIdGenerator? sessionIdGenerator,
|
||||
}) : _filePicker = filePicker,
|
||||
_sessionIdGenerator = sessionIdGenerator ?? _randomSessionId;
|
||||
|
||||
final FirmwareFilePicker _filePicker;
|
||||
final SessionIdGenerator _sessionIdGenerator;
|
||||
|
||||
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
|
||||
final FirmwarePickerSelection? selection;
|
||||
try {
|
||||
selection = await _filePicker.pickFirmwareFile();
|
||||
} catch (error) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.readFailed,
|
||||
message: 'Could not read selected firmware file: $error',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selection == null) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
const FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.canceled,
|
||||
message: 'Firmware selection canceled.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final fileName = selection.fileName.trim();
|
||||
if (fileName.isEmpty) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
const FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.malformedSelection,
|
||||
message: 'Selected firmware file is missing a valid name.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_hasBinExtension(fileName)) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.unsupportedExtension,
|
||||
message:
|
||||
'Unsupported firmware file "$fileName". Please select a .bin file.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.fileBytes.isEmpty) {
|
||||
return FirmwareFileSelectionResult.failed(
|
||||
FirmwareSelectionFailure(
|
||||
reason: FirmwareSelectionFailureReason.emptyFile,
|
||||
message:
|
||||
'Selected firmware file "$fileName" is empty. Choose a non-empty .bin file.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final metadata = DfuV1FirmwareMetadata(
|
||||
totalLength: selection.fileBytes.length,
|
||||
crc32: DfuProtocol.crc32(selection.fileBytes),
|
||||
sessionId: _sessionIdGenerator() & 0xFF,
|
||||
flags: universalShifterDfuFlagNone,
|
||||
);
|
||||
|
||||
return FirmwareFileSelectionResult.success(
|
||||
DfuV1PreparedFirmware(
|
||||
fileName: fileName,
|
||||
filePath: selection.filePath,
|
||||
fileBytes: selection.fileBytes,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasBinExtension(String fileName) {
|
||||
return fileName.toLowerCase().endsWith('.bin');
|
||||
}
|
||||
|
||||
static int _randomSessionId() {
|
||||
return Random.secure().nextInt(256);
|
||||
}
|
||||
}
|
||||
690
lib/service/firmware_update_service.dart
Normal file
690
lib/service/firmware_update_service.dart
Normal file
@ -0,0 +1,690 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
|
||||
const int _initialAckSequence = 0xFF;
|
||||
|
||||
class FirmwareUpdateService {
|
||||
FirmwareUpdateService({
|
||||
required FirmwareUpdateTransport transport,
|
||||
this.defaultWindowSize = 8,
|
||||
this.maxNoProgressRetries = 5,
|
||||
this.defaultAckTimeout = const Duration(milliseconds: 800),
|
||||
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
||||
this.defaultReconnectTimeout = const Duration(seconds: 12),
|
||||
this.defaultVerificationTimeout = const Duration(seconds: 5),
|
||||
}) : _transport = transport;
|
||||
|
||||
final FirmwareUpdateTransport _transport;
|
||||
final int defaultWindowSize;
|
||||
final int maxNoProgressRetries;
|
||||
final Duration defaultAckTimeout;
|
||||
final Duration defaultPostFinishResetTimeout;
|
||||
final Duration defaultReconnectTimeout;
|
||||
final Duration defaultVerificationTimeout;
|
||||
|
||||
final StreamController<DfuUpdateProgress> _progressController =
|
||||
StreamController<DfuUpdateProgress>.broadcast();
|
||||
|
||||
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
|
||||
state: DfuUpdateState.idle,
|
||||
totalBytes: 0,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: 0,
|
||||
flags: DfuUpdateFlags(),
|
||||
);
|
||||
|
||||
StreamSubscription<List<int>>? _ackSubscription;
|
||||
Completer<void>? _ackSignal;
|
||||
Completer<void>? _cancelSignal;
|
||||
int _ackEventCount = 0;
|
||||
String? _ackStreamError;
|
||||
bool _isRunning = false;
|
||||
bool _cancelRequested = false;
|
||||
int _latestAckSequence = _initialAckSequence;
|
||||
int _ackedFrames = 0;
|
||||
int _totalFrames = 0;
|
||||
int _totalBytes = 0;
|
||||
|
||||
Stream<DfuUpdateProgress> get progressStream => _progressController.stream;
|
||||
|
||||
DfuUpdateProgress get currentProgress => _currentProgress;
|
||||
|
||||
bool get isUpdating => _isRunning;
|
||||
|
||||
Future<Result<void>> startUpdate({
|
||||
required List<int> imageBytes,
|
||||
required int sessionId,
|
||||
DfuUpdateFlags flags = const DfuUpdateFlags(),
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
int? windowSize,
|
||||
Duration? ackTimeout,
|
||||
int? noProgressRetries,
|
||||
Duration? postFinishResetTimeout,
|
||||
Duration? reconnectTimeout,
|
||||
Duration? verificationTimeout,
|
||||
}) async {
|
||||
if (_isRunning) {
|
||||
return bail(
|
||||
'Firmware update is already running. Cancel or wait for completion before starting a new upload.');
|
||||
}
|
||||
if (imageBytes.isEmpty) {
|
||||
return bail(
|
||||
'Firmware image is empty. Select a valid .bin file and retry.');
|
||||
}
|
||||
|
||||
final effectiveWindowSize = windowSize ?? defaultWindowSize;
|
||||
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
|
||||
final effectiveNoProgressRetries =
|
||||
noProgressRetries ?? maxNoProgressRetries;
|
||||
final effectivePostFinishResetTimeout =
|
||||
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
|
||||
final effectiveReconnectTimeout =
|
||||
reconnectTimeout ?? defaultReconnectTimeout;
|
||||
final effectiveVerificationTimeout =
|
||||
verificationTimeout ?? defaultVerificationTimeout;
|
||||
|
||||
if (effectiveWindowSize <= 0) {
|
||||
return bail(
|
||||
'DFU window size must be at least 1 frame. Got $effectiveWindowSize.');
|
||||
}
|
||||
if (effectiveNoProgressRetries < 0) {
|
||||
return bail(
|
||||
'No-progress retry limit cannot be negative. Got $effectiveNoProgressRetries.');
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = Completer<void>();
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _initialAckSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames =
|
||||
(imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/
|
||||
universalShifterDfuFramePayloadSizeBytes;
|
||||
_totalBytes = imageBytes.length;
|
||||
|
||||
final normalizedSessionId = sessionId & 0xFF;
|
||||
final crc32 = DfuProtocol.crc32(imageBytes);
|
||||
final frames = DfuProtocol.buildDataFrames(imageBytes);
|
||||
var shouldAbortForCleanup = false;
|
||||
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.starting,
|
||||
totalBytes: imageBytes.length,
|
||||
sentBytes: 0,
|
||||
lastAckedSequence: _initialAckSequence,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags,
|
||||
);
|
||||
|
||||
try {
|
||||
final preflightResult = await _transport.runPreflight(
|
||||
requestedMtu: requestedMtu,
|
||||
);
|
||||
if (preflightResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'DFU preflight check failed due to transport error: ${preflightResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
final preflight = preflightResult.unwrap();
|
||||
if (!preflight.canStart) {
|
||||
throw _DfuFailure(
|
||||
preflight.message ??
|
||||
'DFU preflight failed. Ensure button connection and MTU are ready, then retry.',
|
||||
);
|
||||
}
|
||||
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = _transport.subscribeToAck().listen(
|
||||
_handleAckPayload,
|
||||
onError: (Object error) {
|
||||
_ackStreamError =
|
||||
'ACK indication stream failed: $error. Reconnect and retry the update.';
|
||||
_signalAckWaiters();
|
||||
},
|
||||
);
|
||||
|
||||
_emitProgress(state: DfuUpdateState.waitingForAck);
|
||||
final startEventCount = _ackEventCount;
|
||||
final startWriteResult = await _transport.writeControl(
|
||||
DfuProtocol.encodeStartPayload(
|
||||
DfuStartPayload(
|
||||
totalLength: imageBytes.length,
|
||||
imageCrc32: crc32,
|
||||
sessionId: normalizedSessionId,
|
||||
flags: flags.rawValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (startWriteResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed to send DFU START command: ${startWriteResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
shouldAbortForCleanup = true;
|
||||
|
||||
final initialAck = await _waitForInitialAck(
|
||||
afterEventCount: startEventCount,
|
||||
timeout: effectiveAckTimeout,
|
||||
);
|
||||
if (initialAck != _initialAckSequence) {
|
||||
throw _DfuFailure(
|
||||
'Device did not acknowledge START correctly (expected ACK 0xFF, got 0x${initialAck.toRadixString(16).padLeft(2, '0').toUpperCase()}). Send ABORT, reconnect if needed, and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
_emitProgress(state: DfuUpdateState.transferring);
|
||||
|
||||
var nextFrameIndex = 0;
|
||||
var retriesWithoutProgress = 0;
|
||||
|
||||
while (_ackedFrames < _totalFrames) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
|
||||
final ackedBeforeWindow = _ackedFrames;
|
||||
final endExclusive =
|
||||
(nextFrameIndex + effectiveWindowSize).clamp(0, frames.length);
|
||||
|
||||
for (var frameIndex = nextFrameIndex;
|
||||
frameIndex < endExclusive;
|
||||
frameIndex++) {
|
||||
_throwIfCancelled();
|
||||
final writeResult =
|
||||
await _transport.writeDataFrame(frames[frameIndex].bytes);
|
||||
if (writeResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed sending DFU data frame #$frameIndex (seq 0x${frames[frameIndex].sequence.toRadixString(16).padLeft(2, '0').toUpperCase()}): ${writeResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
nextFrameIndex = endExclusive;
|
||||
|
||||
if (_ackedFrames > ackedBeforeWindow) {
|
||||
retriesWithoutProgress = 0;
|
||||
nextFrameIndex = _ackedFrames;
|
||||
continue;
|
||||
}
|
||||
|
||||
final gotProgress = await _waitForAckProgress(
|
||||
ackedFramesBeforeWait: ackedBeforeWindow,
|
||||
timeout: effectiveAckTimeout,
|
||||
);
|
||||
|
||||
if (gotProgress) {
|
||||
retriesWithoutProgress = 0;
|
||||
nextFrameIndex = _ackedFrames;
|
||||
continue;
|
||||
}
|
||||
|
||||
retriesWithoutProgress += 1;
|
||||
if (retriesWithoutProgress > effectiveNoProgressRetries) {
|
||||
throw _DfuFailure(
|
||||
'Upload stalled: no ACK progress after $retriesWithoutProgress retries (last ACK 0x${_latestAckSequence.toRadixString(16).padLeft(2, '0').toUpperCase()}). Check BLE signal quality and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
nextFrameIndex = _ackedFrames;
|
||||
}
|
||||
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.finishing, sentBytes: imageBytes.length);
|
||||
final finishResult =
|
||||
await _transport.writeControl(DfuProtocol.encodeFinishPayload());
|
||||
if (finishResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Failed to send DFU FINISH command: ${finishResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
|
||||
final resetDisconnectResult =
|
||||
await _transport.waitForExpectedResetDisconnect(
|
||||
timeout: effectivePostFinishResetTimeout,
|
||||
);
|
||||
if (resetDisconnectResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
final reconnectResult = await _transport.reconnectForVerification(
|
||||
timeout: effectiveReconnectTimeout,
|
||||
);
|
||||
if (reconnectResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device did not reconnect after DFU reset: ${reconnectResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
|
||||
final verificationResult = await _transport.verifyDeviceReachable(
|
||||
timeout: effectiveVerificationTimeout,
|
||||
);
|
||||
if (verificationResult.isErr()) {
|
||||
throw _DfuFailure(
|
||||
'Device reconnected but post-update verification failed: ${verificationResult.unwrapErr()} '
|
||||
'Firmware version cannot be compared yet because the device does not expose a version characteristic.',
|
||||
);
|
||||
}
|
||||
|
||||
shouldAbortForCleanup = false;
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
|
||||
return Ok(null);
|
||||
} on _DfuCancelled {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
_emitProgress(state: DfuUpdateState.aborted);
|
||||
return bail('Firmware update canceled by user.');
|
||||
} on _DfuFailure catch (failure) {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
_emitProgress(
|
||||
state: DfuUpdateState.failed, errorMessage: failure.message);
|
||||
return bail(failure.message);
|
||||
} catch (error) {
|
||||
if (shouldAbortForCleanup) {
|
||||
await _sendAbortForCleanup();
|
||||
}
|
||||
final message =
|
||||
'Firmware update failed unexpectedly: $error. Reconnect to the button and retry.';
|
||||
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
|
||||
return bail(message);
|
||||
} finally {
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
_isRunning = false;
|
||||
_cancelRequested = false;
|
||||
_cancelSignal = null;
|
||||
_ackSignal = null;
|
||||
_ackEventCount = 0;
|
||||
_ackStreamError = null;
|
||||
_latestAckSequence = _currentProgress.lastAckedSequence;
|
||||
_ackedFrames = 0;
|
||||
_totalFrames = 0;
|
||||
_totalBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelUpdate() async {
|
||||
if (!_isRunning || _cancelRequested) {
|
||||
return;
|
||||
}
|
||||
_cancelRequested = true;
|
||||
_cancelSignal?.complete();
|
||||
_signalAckWaiters();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await cancelUpdate();
|
||||
await _ackSubscription?.cancel();
|
||||
_ackSubscription = null;
|
||||
await _progressController.close();
|
||||
}
|
||||
|
||||
void _handleAckPayload(List<int> payload) {
|
||||
try {
|
||||
final sequence = DfuProtocol.parseAckPayload(payload);
|
||||
final previousAck = _latestAckSequence;
|
||||
_latestAckSequence = sequence;
|
||||
|
||||
if (_totalFrames > 0 &&
|
||||
_currentProgress.state == DfuUpdateState.transferring) {
|
||||
final delta = DfuProtocol.sequenceDistance(previousAck, sequence);
|
||||
if (delta > 0) {
|
||||
_ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames);
|
||||
}
|
||||
|
||||
_emitProgress(
|
||||
lastAckedSequence: sequence,
|
||||
sentBytes:
|
||||
_ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes),
|
||||
);
|
||||
} else {
|
||||
_emitProgress(lastAckedSequence: sequence);
|
||||
}
|
||||
} on FormatException catch (error) {
|
||||
_ackStreamError =
|
||||
'Received malformed ACK indication: $error. Reconnect and retry.';
|
||||
} finally {
|
||||
_ackEventCount += 1;
|
||||
_signalAckWaiters();
|
||||
}
|
||||
}
|
||||
|
||||
void _emitProgress({
|
||||
DfuUpdateState? state,
|
||||
int? totalBytes,
|
||||
int? sentBytes,
|
||||
int? lastAckedSequence,
|
||||
int? sessionId,
|
||||
DfuUpdateFlags? flags,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
final next = DfuUpdateProgress(
|
||||
state: state ?? _currentProgress.state,
|
||||
totalBytes: totalBytes ?? _currentProgress.totalBytes,
|
||||
sentBytes: sentBytes ?? _currentProgress.sentBytes,
|
||||
lastAckedSequence:
|
||||
lastAckedSequence ?? _currentProgress.lastAckedSequence,
|
||||
sessionId: sessionId ?? _currentProgress.sessionId,
|
||||
flags: flags ?? _currentProgress.flags,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
_currentProgress = next;
|
||||
_progressController.add(next);
|
||||
}
|
||||
|
||||
Future<int> _waitForInitialAck({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
var observedEvents = afterEventCount;
|
||||
|
||||
while (true) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
final remaining = deadline.difference(DateTime.now());
|
||||
if (remaining <= Duration.zero) {
|
||||
throw _DfuFailure(
|
||||
'Timed out waiting for initial DFU ACK after START. Ensure indications are enabled and retry.',
|
||||
);
|
||||
}
|
||||
|
||||
final gotEvent = await _waitForNextAckEvent(
|
||||
afterEventCount: observedEvents,
|
||||
timeout: remaining,
|
||||
);
|
||||
if (!gotEvent) {
|
||||
continue;
|
||||
}
|
||||
observedEvents = _ackEventCount;
|
||||
return _latestAckSequence;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForAckProgress({
|
||||
required int ackedFramesBeforeWait,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
var observedEvents = _ackEventCount;
|
||||
|
||||
while (true) {
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
|
||||
if (_ackedFrames > ackedFramesBeforeWait) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final remaining = deadline.difference(DateTime.now());
|
||||
if (remaining <= Duration.zero) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final gotEvent = await _waitForNextAckEvent(
|
||||
afterEventCount: observedEvents,
|
||||
timeout: remaining,
|
||||
);
|
||||
|
||||
if (!gotEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
observedEvents = _ackEventCount;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitForNextAckEvent({
|
||||
required int afterEventCount,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
if (_ackEventCount > afterEventCount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_ackSignal ??= Completer<void>();
|
||||
final signal = _ackSignal!;
|
||||
|
||||
try {
|
||||
await Future.any<void>([
|
||||
signal.future,
|
||||
_cancelSignal?.future ?? Future<void>.value(),
|
||||
]).timeout(timeout);
|
||||
} on TimeoutException {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (identical(_ackSignal, signal)) {
|
||||
_ackSignal = null;
|
||||
}
|
||||
|
||||
_throwIfCancelled();
|
||||
_throwIfAckStreamErrored();
|
||||
return _ackEventCount > afterEventCount;
|
||||
}
|
||||
|
||||
void _throwIfCancelled() {
|
||||
if (_cancelRequested) {
|
||||
throw const _DfuCancelled();
|
||||
}
|
||||
}
|
||||
|
||||
void _throwIfAckStreamErrored() {
|
||||
final error = _ackStreamError;
|
||||
if (error != null) {
|
||||
throw _DfuFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendAbortForCleanup() async {
|
||||
final result =
|
||||
await _transport.writeControl(DfuProtocol.encodeAbortPayload());
|
||||
if (result.isErr()) {
|
||||
final cleanupMessage =
|
||||
'Could not send DFU ABORT during cleanup: ${result.unwrapErr()}';
|
||||
if (_currentProgress.state == DfuUpdateState.failed &&
|
||||
_currentProgress.errorMessage != null) {
|
||||
_emitProgress(
|
||||
errorMessage: '${_currentProgress.errorMessage} $cleanupMessage',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _signalAckWaiters() {
|
||||
final signal = _ackSignal;
|
||||
if (signal != null && !signal.isCompleted) {
|
||||
signal.complete();
|
||||
}
|
||||
}
|
||||
|
||||
int _ackedBytesFromFrames(int ackedFrames, int totalFrames, int totalBytes) {
|
||||
if (totalFrames == 0 || ackedFrames <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (ackedFrames >= totalFrames) {
|
||||
return totalBytes;
|
||||
}
|
||||
return ackedFrames * universalShifterDfuFramePayloadSizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class FirmwareUpdateTransport {
|
||||
Future<Result<DfuPreflightResult>> runPreflight({required int requestedMtu});
|
||||
|
||||
Stream<List<int>> subscribeToAck();
|
||||
|
||||
Future<Result<void>> writeControl(List<int> payload);
|
||||
|
||||
Future<Result<void>> writeDataFrame(List<int> frame);
|
||||
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
});
|
||||
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
});
|
||||
|
||||
/// Verifies that the device is reachable after reconnect.
|
||||
///
|
||||
/// Current limitation: strict firmware version comparison is not possible
|
||||
/// yet because no firmware version characteristic is exposed by the device.
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
});
|
||||
}
|
||||
|
||||
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
ShifterFirmwareUpdateTransport({
|
||||
required this.shifterService,
|
||||
required this.bluetoothController,
|
||||
required this.buttonDeviceId,
|
||||
});
|
||||
|
||||
final ShifterService shifterService;
|
||||
final BluetoothController bluetoothController;
|
||||
final String buttonDeviceId;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) {
|
||||
return shifterService.runDfuPreflight(requestedMtu: requestedMtu);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() {
|
||||
return bluetoothController.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuAckCharacteristicUuid,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuControlCharacteristicUuid,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) {
|
||||
return bluetoothController.writeCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterDfuDataCharacteristicUuid,
|
||||
frame,
|
||||
withResponse: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final currentState = bluetoothController.currentConnectionState;
|
||||
if (currentState.$1 == ConnectionStatus.disconnected) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await bluetoothController.connectionStateStream
|
||||
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
|
||||
.timeout(timeout);
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms waiting for the expected reset disconnect.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Failed while waiting for expected reset disconnect: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
final connectResult =
|
||||
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
|
||||
if (connectResult.isErr()) {
|
||||
return bail(connectResult.unwrapErr());
|
||||
}
|
||||
|
||||
final currentState = bluetoothController.currentConnectionState;
|
||||
if (currentState.$1 == ConnectionStatus.connected &&
|
||||
currentState.$2 == buttonDeviceId) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await bluetoothController.connectionStateStream
|
||||
.firstWhere(
|
||||
(state) =>
|
||||
state.$1 == ConnectionStatus.connected &&
|
||||
state.$2 == buttonDeviceId,
|
||||
)
|
||||
.timeout(timeout);
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms waiting for reconnect.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Reconnect wait failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
try {
|
||||
final statusResult = await shifterService.readStatus().timeout(timeout);
|
||||
if (statusResult.isErr()) {
|
||||
return bail(statusResult.unwrapErr());
|
||||
}
|
||||
return Ok(null);
|
||||
} on TimeoutException {
|
||||
return bail(
|
||||
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
|
||||
);
|
||||
} catch (error) {
|
||||
return bail('Post-update verification failed: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DfuFailure implements Exception {
|
||||
const _DfuFailure(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class _DfuCancelled implements Exception {
|
||||
const _DfuCancelled();
|
||||
}
|
||||
@ -6,12 +6,30 @@ import 'package:anyhow/anyhow.dart';
|
||||
|
||||
class ShifterService {
|
||||
ShifterService({
|
||||
required BluetoothController bluetooth,
|
||||
BluetoothController? bluetooth,
|
||||
required this.buttonDeviceId,
|
||||
}) : _bluetooth = bluetooth;
|
||||
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
|
||||
}) : _bluetooth = bluetooth,
|
||||
_dfuPreflightBluetooth =
|
||||
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
|
||||
if (bluetooth == null && dfuPreflightBluetooth == null) {
|
||||
throw ArgumentError(
|
||||
'Either bluetooth or dfuPreflightBluetooth must be provided.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final BluetoothController _bluetooth;
|
||||
final BluetoothController? _bluetooth;
|
||||
final String buttonDeviceId;
|
||||
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
|
||||
|
||||
BluetoothController get _requireBluetooth {
|
||||
final bluetooth = _bluetooth;
|
||||
if (bluetooth == null) {
|
||||
throw StateError('Bluetooth controller is not available.');
|
||||
}
|
||||
return bluetooth;
|
||||
}
|
||||
|
||||
final StreamController<CentralStatus> _statusController =
|
||||
StreamController<CentralStatus>.broadcast();
|
||||
@ -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,
|
||||
|
||||
@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import file_picker
|
||||
import flutter_blue_plus_darwin
|
||||
import nb_utils
|
||||
import path_provider_foundation
|
||||
@ -15,6 +16,7 @@ import sqlite3_flutter_libs
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
|
||||
32
pubspec.lock
32
pubspec.lock
@ -233,6 +233,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -345,6 +353,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.7"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -427,6 +443,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_reactive_ble:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1187,6 +1211,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.13.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -54,6 +54,7 @@ dependencies:
|
||||
flutter_reactive_ble: ^5.4.0
|
||||
nb_utils: ^7.2.0
|
||||
cbor: ^6.3.3
|
||||
file_picker: ^8.1.7
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
137
test/service/dfu_preflight_test.dart
Normal file
137
test/service/dfu_preflight_test.dart
Normal file
@ -0,0 +1,137 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ShifterService.runDfuPreflight', () {
|
||||
test('fails when no active button connection exists', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.disconnected, null),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason,
|
||||
DfuPreflightFailureReason.deviceNotConnected);
|
||||
expect(adapter.requestMtuCallCount, 0);
|
||||
});
|
||||
|
||||
test('fails when connected to a different button', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'wrong-device'),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason,
|
||||
DfuPreflightFailureReason.wrongConnectedDevice);
|
||||
expect(adapter.requestMtuCallCount, 0);
|
||||
});
|
||||
|
||||
test('fails when MTU negotiation fails', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: bail('adapter rejected mtu request'),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight(requestedMtu: 247);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(
|
||||
preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed);
|
||||
expect(preflight.message, contains('adapter rejected mtu request'));
|
||||
expect(adapter.requestedMtuValues, [247]);
|
||||
});
|
||||
|
||||
test('fails when negotiated MTU is too low for 64-byte frame writes',
|
||||
() async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: Ok(universalShifterDfuMinimumMtu - 1),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isFalse);
|
||||
expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow);
|
||||
expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1);
|
||||
expect(preflight.requiredMtu, universalShifterDfuMinimumMtu);
|
||||
});
|
||||
|
||||
test('passes when connected to target and MTU is sufficient', () async {
|
||||
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||
mtuResult: Ok(128),
|
||||
);
|
||||
final service = ShifterService(
|
||||
buttonDeviceId: 'target-device',
|
||||
dfuPreflightBluetooth: adapter,
|
||||
);
|
||||
|
||||
final result = await service.runDfuPreflight();
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
final preflight = result.unwrap();
|
||||
expect(preflight.canStart, isTrue);
|
||||
expect(preflight.failureReason, isNull);
|
||||
expect(preflight.negotiatedMtu, 128);
|
||||
expect(preflight.requestedMtu, universalShifterDfuPreferredMtu);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeDfuPreflightBluetoothAdapter
|
||||
implements DfuPreflightBluetoothAdapter {
|
||||
_FakeDfuPreflightBluetoothAdapter({
|
||||
required this.currentConnectionState,
|
||||
required Result<int> mtuResult,
|
||||
}) : _mtuResult = mtuResult;
|
||||
|
||||
@override
|
||||
final (ConnectionStatus, String?) currentConnectionState;
|
||||
|
||||
final Result<int> _mtuResult;
|
||||
|
||||
int requestMtuCallCount = 0;
|
||||
final List<int> requestedMtuValues = <int>[];
|
||||
|
||||
@override
|
||||
Future<Result<int>> requestMtuAndGetValue(
|
||||
String deviceId, {
|
||||
required int mtu,
|
||||
}) async {
|
||||
requestMtuCallCount += 1;
|
||||
requestedMtuValues.add(mtu);
|
||||
return _mtuResult;
|
||||
}
|
||||
}
|
||||
86
test/service/dfu_protocol_test.dart
Normal file
86
test/service/dfu_protocol_test.dart
Normal file
@ -0,0 +1,86 @@
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('DfuProtocol CRC32', () {
|
||||
test('matches known vector', () {
|
||||
final crc = DfuProtocol.crc32('123456789'.codeUnits);
|
||||
expect(crc, 0xCBF43926);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol control payload encoding', () {
|
||||
test('encodes START payload with exact 11-byte LE layout', () {
|
||||
final payload = DfuProtocol.encodeStartPayload(
|
||||
const DfuStartPayload(
|
||||
totalLength: 0x1234,
|
||||
imageCrc32: 0x89ABCDEF,
|
||||
sessionId: 0x22,
|
||||
flags: universalShifterDfuFlagEncrypted,
|
||||
),
|
||||
);
|
||||
|
||||
expect(payload.length, 11);
|
||||
expect(
|
||||
payload,
|
||||
[
|
||||
universalShifterDfuOpcodeStart,
|
||||
0x34,
|
||||
0x12,
|
||||
0x00,
|
||||
0x00,
|
||||
0xEF,
|
||||
0xCD,
|
||||
0xAB,
|
||||
0x89,
|
||||
0x22,
|
||||
universalShifterDfuFlagEncrypted,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes FINISH and ABORT payloads as one byte', () {
|
||||
expect(
|
||||
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
|
||||
expect(
|
||||
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol data frame building', () {
|
||||
test('builds 64-byte frames and handles final partial payload', () {
|
||||
final image = List<int>.generate(80, (index) => index);
|
||||
final frames = DfuProtocol.buildDataFrames(image);
|
||||
|
||||
expect(frames.length, 2);
|
||||
|
||||
expect(frames[0].sequence, 0);
|
||||
expect(frames[0].offset, 0);
|
||||
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
|
||||
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
|
||||
|
||||
expect(frames[1].sequence, 1);
|
||||
expect(frames[1].offset, 63);
|
||||
expect(frames[1].payloadLength, 17);
|
||||
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
|
||||
});
|
||||
});
|
||||
|
||||
group('DfuProtocol sequence and ACK helpers', () {
|
||||
test('wraps sequence values and computes ack+1 rewind', () {
|
||||
expect(DfuProtocol.nextSequence(0x00), 0x01);
|
||||
expect(DfuProtocol.nextSequence(0xFF), 0x00);
|
||||
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
|
||||
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
|
||||
});
|
||||
|
||||
test('computes wrapping sequence distance', () {
|
||||
expect(DfuProtocol.sequenceDistance(250, 2), 8);
|
||||
expect(DfuProtocol.sequenceDistance(1, 1), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
132
test/service/firmware_file_selection_service_test.dart
Normal file
132
test/service/firmware_file_selection_service_test.dart
Normal file
@ -0,0 +1,132 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareFileSelectionService', () {
|
||||
test('prepares v1 metadata for selected .bin firmware', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.BIN',
|
||||
filePath: '/tmp/firmware.BIN',
|
||||
fileBytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => 0x1AB,
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
expect(result.isSuccess, isTrue);
|
||||
|
||||
final firmware = result.firmware!;
|
||||
expect(firmware.fileName, 'firmware.BIN');
|
||||
expect(firmware.filePath, '/tmp/firmware.BIN');
|
||||
expect(firmware.fileBytes, <int>[1, 2, 3, 4]);
|
||||
expect(firmware.metadata.totalLength, 4);
|
||||
expect(firmware.metadata.crc32, 0xB63CFBCD);
|
||||
expect(firmware.metadata.sessionId, 0xAB);
|
||||
expect(firmware.metadata.flags, universalShifterDfuFlagNone);
|
||||
});
|
||||
|
||||
test('returns canceled result when user dismisses picker', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(selection: null),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.isCanceled, isTrue);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.canceled);
|
||||
});
|
||||
|
||||
test('rejects unsupported extension', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.hex',
|
||||
fileBytes: Uint8List.fromList(<int>[1]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason,
|
||||
FirmwareSelectionFailureReason.unsupportedExtension);
|
||||
});
|
||||
|
||||
test('rejects empty payload', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List(0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.emptyFile);
|
||||
});
|
||||
|
||||
test('generates session id per run', () async {
|
||||
var nextSession = 9;
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: FirmwarePickerSelection(
|
||||
fileName: 'firmware.bin',
|
||||
fileBytes: Uint8List.fromList(<int>[10]),
|
||||
),
|
||||
),
|
||||
sessionIdGenerator: () => nextSession++,
|
||||
);
|
||||
|
||||
final first = await service.selectAndPrepareDfuV1();
|
||||
final second = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(first.firmware?.metadata.sessionId, 9);
|
||||
expect(second.firmware?.metadata.sessionId, 10);
|
||||
});
|
||||
|
||||
test('maps picker read failure to explicit validation error', () async {
|
||||
final service = FirmwareFileSelectionService(
|
||||
filePicker: _FakeFirmwareFilePicker(
|
||||
selection: null,
|
||||
error: const FormatException('broken pick payload'),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await service.selectAndPrepareDfuV1();
|
||||
|
||||
expect(result.isSuccess, isFalse);
|
||||
expect(result.failure?.reason, FirmwareSelectionFailureReason.readFailed);
|
||||
expect(result.failure?.message, contains('broken pick payload'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareFilePicker implements FirmwareFilePicker {
|
||||
_FakeFirmwareFilePicker({
|
||||
required this.selection,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final FirmwarePickerSelection? selection;
|
||||
final Object? error;
|
||||
|
||||
@override
|
||||
Future<FirmwarePickerSelection?> pickFirmwareFile() async {
|
||||
if (error != null) {
|
||||
throw error!;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
350
test/service/firmware_update_service_test.dart
Normal file
350
test/service/firmware_update_service_test.dart
Normal file
@ -0,0 +1,350 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('FirmwareUpdateService', () {
|
||||
test('completes happy path with START, data frames, and FINISH', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport();
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 7,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.controlWrites.length, 2);
|
||||
expect(
|
||||
transport.controlWrites.first.first, universalShifterDfuOpcodeStart);
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeFinish]);
|
||||
expect(transport.dataWrites.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
expect(service.currentProgress.sentBytes, image.length);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('rewinds to ack+1 and retransmits after ACK stall', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(dropFirstSequence: 1);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 3,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
maxNoProgressRetries: 4,
|
||||
);
|
||||
|
||||
final image = List<int>.generate(190, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 9,
|
||||
);
|
||||
|
||||
expect(result.isOk(), isTrue);
|
||||
expect(transport.dataWrites.length, greaterThan(4));
|
||||
expect(transport.sequenceWriteCount(1), greaterThan(1));
|
||||
expect(service.currentProgress.state, DfuUpdateState.completed);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('cancel sends ABORT and reports aborted state', () async {
|
||||
final firstFrameSent = Completer<void>();
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
onDataWrite: (frame) {
|
||||
if (!firstFrameSent.isCompleted) {
|
||||
firstFrameSent.complete();
|
||||
}
|
||||
},
|
||||
suppressDataAcks: true,
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 1,
|
||||
defaultAckTimeout: const Duration(milliseconds: 500),
|
||||
);
|
||||
|
||||
final future = service.startUpdate(
|
||||
imageBytes: List<int>.generate(90, (index) => index & 0xFF),
|
||||
sessionId: 11,
|
||||
);
|
||||
|
||||
await firstFrameSent.future.timeout(const Duration(seconds: 1));
|
||||
await service.cancelUpdate();
|
||||
final result = await future;
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('canceled'));
|
||||
expect(transport.controlWrites.last, [universalShifterDfuOpcodeAbort]);
|
||||
expect(service.currentProgress.state, DfuUpdateState.aborted);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when reconnect does not succeed after expected reset',
|
||||
() async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
reconnectError: 'simulated reconnect timeout',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 13,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(result.unwrapErr().toString(), contains('did not reconnect'));
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when expected reset disconnect is not observed', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
resetDisconnectError: 'simulated missing disconnect',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 15,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('expected post-FINISH reset disconnect'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
['waitForExpectedResetDisconnect'],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test('fails when post-update status verification read fails', () async {
|
||||
final transport = _FakeFirmwareUpdateTransport(
|
||||
verificationError: 'simulated status read failure',
|
||||
);
|
||||
final service = FirmwareUpdateService(
|
||||
transport: transport,
|
||||
defaultWindowSize: 4,
|
||||
defaultAckTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
final image = List<int>.generate(130, (index) => index & 0xFF);
|
||||
final result = await service.startUpdate(
|
||||
imageBytes: image,
|
||||
sessionId: 14,
|
||||
);
|
||||
|
||||
expect(result.isErr(), isTrue);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('post-update verification failed'),
|
||||
);
|
||||
expect(
|
||||
result.unwrapErr().toString(),
|
||||
contains('does not expose a version characteristic'),
|
||||
);
|
||||
expect(service.currentProgress.state, DfuUpdateState.failed);
|
||||
expect(
|
||||
transport.postFinishSteps,
|
||||
[
|
||||
'waitForExpectedResetDisconnect',
|
||||
'reconnectForVerification',
|
||||
'verifyDeviceReachable',
|
||||
],
|
||||
);
|
||||
|
||||
await service.dispose();
|
||||
await transport.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
||||
_FakeFirmwareUpdateTransport({
|
||||
this.dropFirstSequence,
|
||||
this.onDataWrite,
|
||||
this.suppressDataAcks = false,
|
||||
this.resetDisconnectError,
|
||||
this.reconnectError,
|
||||
this.verificationError,
|
||||
});
|
||||
|
||||
final int? dropFirstSequence;
|
||||
final void Function(List<int> frame)? onDataWrite;
|
||||
final bool suppressDataAcks;
|
||||
final String? resetDisconnectError;
|
||||
final String? reconnectError;
|
||||
final String? verificationError;
|
||||
|
||||
final StreamController<List<int>> _ackController =
|
||||
StreamController<List<int>>.broadcast();
|
||||
|
||||
final List<List<int>> controlWrites = <List<int>>[];
|
||||
final List<List<int>> dataWrites = <List<int>>[];
|
||||
final List<String> postFinishSteps = <String>[];
|
||||
final Set<int> _droppedOnce = <int>{};
|
||||
int _lastAck = 0xFF;
|
||||
int _expectedSequence = 0;
|
||||
|
||||
@override
|
||||
Future<Result<DfuPreflightResult>> runPreflight({
|
||||
required int requestedMtu,
|
||||
}) async {
|
||||
return Ok(
|
||||
DfuPreflightResult.ready(
|
||||
requestedMtu: requestedMtu,
|
||||
negotiatedMtu: 128,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<int>> subscribeToAck() => _ackController.stream;
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeControl(List<int> payload) async {
|
||||
controlWrites.add(List<int>.from(payload, growable: false));
|
||||
|
||||
final opcode = payload.isEmpty ? -1 : payload.first;
|
||||
if (opcode == universalShifterDfuOpcodeStart) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([0xFF]);
|
||||
});
|
||||
}
|
||||
|
||||
if (opcode == universalShifterDfuOpcodeAbort) {
|
||||
_lastAck = 0xFF;
|
||||
_expectedSequence = 0;
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> writeDataFrame(List<int> frame) async {
|
||||
dataWrites.add(List<int>.from(frame, growable: false));
|
||||
onDataWrite?.call(frame);
|
||||
|
||||
if (suppressDataAcks) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
final sequence = frame.first;
|
||||
final shouldDrop = dropFirstSequence != null &&
|
||||
sequence == dropFirstSequence &&
|
||||
!_droppedOnce.contains(sequence);
|
||||
|
||||
if (shouldDrop) {
|
||||
_droppedOnce.add(sequence);
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
if (sequence == _expectedSequence) {
|
||||
_lastAck = sequence;
|
||||
_expectedSequence = (_expectedSequence + 1) & 0xFF;
|
||||
}
|
||||
|
||||
scheduleMicrotask(() {
|
||||
_ackController.add([_lastAck]);
|
||||
});
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> waitForExpectedResetDisconnect({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('waitForExpectedResetDisconnect');
|
||||
if (resetDisconnectError != null) {
|
||||
return bail(resetDisconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> reconnectForVerification({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('reconnectForVerification');
|
||||
if (reconnectError != null) {
|
||||
return bail(reconnectError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
postFinishSteps.add('verifyDeviceReachable');
|
||||
if (verificationError != null) {
|
||||
return bail(verificationError!);
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
int sequenceWriteCount(int sequence) {
|
||||
var count = 0;
|
||||
for (final frame in dataWrites) {
|
||||
if (frame.first == sequence) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _ackController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user