Compare commits
4 Commits
dd2afa34ef
...
32f258a492
| Author | SHA1 | Date | |
|---|---|---|---|
| 32f258a492 | |||
| c581b4d92c | |||
| aafa9928ac | |||
| 8b24084f97 |
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"last_dolt_commit": "a1bnp9adg85lrejalunb82003iq8uqs4",
|
"last_dolt_commit": "sj669kgsnkm472b9vv8ejj7tuniiibjr",
|
||||||
"last_event_id": 0,
|
"last_event_id": 0,
|
||||||
"timestamp": "2026-03-03T15:55:11.098727696Z",
|
"timestamp": "2026-03-03T16:11:51.022190652Z",
|
||||||
"counts": {
|
"counts": {
|
||||||
"issues": 10,
|
"issues": 10,
|
||||||
"events": 16,
|
"events": 21,
|
||||||
"comments": 0,
|
"comments": 0,
|
||||||
"dependencies": 21,
|
"dependencies": 21,
|
||||||
"labels": 0,
|
"labels": 0,
|
||||||
|
|||||||
@ -14,3 +14,8 @@
|
|||||||
{"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: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: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: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\"}"}
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
{"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":"- 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":"- 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":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":"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":"open","target":"","timeout_ns":0,"title":"Integrate firmware update UI into device details page","updated_at":"2026-03-03T15:39:10Z","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":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":"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":"open","target":"","timeout_ns":0,"title":"Integrate firmware update UI into device details page","updated_at":"2026-03-03T15:39:10Z","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":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":"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":"open","target":"","timeout_ns":0,"title":"Add firmware file selection and binary validation flow","updated_at":"2026-03-03T15:39:10Z","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":"- 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":"- 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":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":"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":"open","target":"","timeout_ns":0,"title":"Implement BLE DFU transfer engine with cumulative ACK retransmit","updated_at":"2026-03-03T15:39:18Z","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":"- 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":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":"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":"open","target":"","timeout_ns":0,"title":"Handle post-FINISH disconnect/reboot and reconnect verification","updated_at":"2026-03-03T15:39:28Z","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":"","closed_at":null,"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":"in_progress","target":"","timeout_ns":0,"title":"Handle post-FINISH disconnect/reboot and reconnect verification","updated_at":"2026-03-03T16:07:21Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,7 @@ const int universalShifterDfuPreferredMtu = 128;
|
|||||||
|
|
||||||
const int universalShifterDfuFlagEncrypted = 0x01;
|
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||||
const int universalShifterDfuFlagSigned = 0x02;
|
const int universalShifterDfuFlagSigned = 0x02;
|
||||||
|
const int universalShifterDfuFlagNone = 0x00;
|
||||||
|
|
||||||
const int errorSequence = 1;
|
const int errorSequence = 1;
|
||||||
const int errorFtmsMissing = 2;
|
const int errorFtmsMissing = 2;
|
||||||
|
|||||||
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,6 +6,7 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import connectivity_plus
|
import connectivity_plus
|
||||||
|
import file_picker
|
||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
import nb_utils
|
import nb_utils
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
@ -15,6 +16,7 @@ import sqlite3_flutter_libs
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@ -233,6 +233,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -345,6 +353,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -427,6 +443,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
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:
|
flutter_reactive_ble:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1187,6 +1211,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.13.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -54,6 +54,7 @@ dependencies:
|
|||||||
flutter_reactive_ble: ^5.4.0
|
flutter_reactive_ble: ^5.4.0
|
||||||
nb_utils: ^7.2.0
|
nb_utils: ^7.2.0
|
||||||
cbor: ^6.3.3
|
cbor: ^6.3.3
|
||||||
|
file_picker: ^8.1.7
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
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