Compare commits
4 Commits
08405c879b
...
dd2afa34ef
| Author | SHA1 | Date | |
|---|---|---|---|
| dd2afa34ef | |||
| fb85565854 | |||
| 7a33e71410 | |||
| e704f27a96 |
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"last_dolt_commit": "6ork55emq2c5smq0bgf8nj7g2bssqsja",
|
"last_dolt_commit": "a1bnp9adg85lrejalunb82003iq8uqs4",
|
||||||
"last_event_id": 0,
|
"last_event_id": 0,
|
||||||
"timestamp": "2026-03-03T15:37:50.206534932Z",
|
"timestamp": "2026-03-03T15:55:11.098727696Z",
|
||||||
"counts": {
|
"counts": {
|
||||||
"issues": 1,
|
"issues": 10,
|
||||||
"events": 1,
|
"events": 16,
|
||||||
"comments": 0,
|
"comments": 0,
|
||||||
"dependencies": 0,
|
"dependencies": 21,
|
||||||
"labels": 0,
|
"labels": 0,
|
||||||
"config": 11
|
"config": 11
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
{"created_at":"2026-03-03T16:38:33Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.1","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.2","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.2","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.3","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.4","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.4","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.5","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.5","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.6","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.3","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.5","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.9","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:17Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.7","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.2","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.8","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:23Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.8","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.8","type":"blocks"}
|
||||||
|
{"created_at":"2026-03-03T16:39:28Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.9","type":"parent-child"}
|
||||||
|
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.9","type":"blocks"}
|
||||||
|
|||||||
@ -1 +1,16 @@
|
|||||||
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:37:50Z","event_type":"created","id":1,"issue_id":"abawo_bt_app-20q","new_value":"","old_value":""}
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:37:50Z","event_type":"created","id":1,"issue_id":"abawo_bt_app-20q","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:38:33Z","event_type":"created","id":2,"issue_id":"abawo_bt_app-20q.1","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":3,"issue_id":"abawo_bt_app-20q.2","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":5,"issue_id":"abawo_bt_app-20q.3","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":7,"issue_id":"abawo_bt_app-20q.4","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":9,"issue_id":"abawo_bt_app-20q.5","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":10,"issue_id":"abawo_bt_app-20q.6","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:17Z","event_type":"created","id":11,"issue_id":"abawo_bt_app-20q.7","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:23Z","event_type":"created","id":12,"issue_id":"abawo_bt_app-20q.8","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:28Z","event_type":"created","id":13,"issue_id":"abawo_bt_app-20q.9","new_value":"","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:43:26Z","event_type":"claimed","id":14,"issue_id":"abawo_bt_app-20q.1","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.1\",\"title\":\"Add DFU protocol constants and domain models\",\"description\":\"Add protocol surface in app code for DFU support.\\n\\nWork:\\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\\n- Add flags constants and typed update-state/progress models used by service/UI\\n- Remove future magic numbers by centralizing constants\",\"acceptance_criteria\":\"- All protocol constants match spec exactly\\n- No duplicated literal DFU UUID/opcode values across services/UI\\n- Domain models compile and are ready for transfer engine integration\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:38:34Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:38:34Z\"}"}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:19Z","event_type":"closed","id":15,"issue_id":"abawo_bt_app-20q.1","new_value":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:29Z","event_type":"claimed","id":16,"issue_id":"abawo_bt_app-20q.2","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.2\",\"title\":\"Implement DFU packet codec and CRC32 utilities with tests\",\"description\":\"Implement pure protocol helpers.\\n\\nWork:\\n- Build START payload (11 bytes, LE fields)\\n- Build FINISH/ABORT payloads\\n- Build/segment DATA frames (seq + 63-byte payload)\\n- Implement ACK/sequence helpers including wrapping behavior\\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)\",\"acceptance_criteria\":\"- CRC test vector passes: \\\"123456789\\\" =\\u003e 0xCBF43926\\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\\n- Frame segmentation handles final partial payload correctly\\n- Seq wrap and ack+1 rewind helpers covered by tests\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:48:57Z","event_type":"closed","id":17,"issue_id":"abawo_bt_app-20q.2","new_value":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","old_value":""}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:49:06Z","event_type":"claimed","id":18,"issue_id":"abawo_bt_app-20q.8","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.8\",\"title\":\"Add BLE DFU preflight checks (MTU and connection readiness)\",\"description\":\"Add runtime guards required for protocol correctness.\\n\\nWork:\\n- Ensure active connection to target button before DFU start\\n- Request elevated MTU (e.g. 128/247) before upload\\n- Validate negotiated MTU supports 64-byte data writes (ATT payload requirement)\\n- Fail early with actionable message when transport preconditions are not met\",\"acceptance_criteria\":\"- Upload start is blocked when MTU/connection preconditions fail\\n- Error messages explain what failed and next step\\n- Preflight result is exposed for transfer start path\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:24Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:24Z\"}"}
|
||||||
|
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:55:10Z","event_type":"closed","id":19,"issue_id":"abawo_bt_app-20q.8","new_value":"Implemented DFU preflight checks for connection state and negotiated MTU with typed results and tests","old_value":""}
|
||||||
|
|||||||
@ -1 +1,10 @@
|
|||||||
{"acceptance_criteria":"- User can select a firmware .bin and complete upload end-to-end\n- Upload follows protocol and handles packet loss via retransmit\n- App shows clear progress and failure states\n- Device reconnects after reboot and is reachable","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"c3bd8fa9b5b9d51b04cde5e11c2d8cdedaca29e049a64e8521172a69093b4ba3","created_at":"2026-03-03T15:37:50Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement the firmware update flow defined in universal-shifters/update-process.md for the Flutter app.\n\nScope:\n- Manual local .bin selection and upload over BLE GATT\n- START/DATA/FINISH/ABORT protocol support\n- Cumulative ACK handling with retransmit\n- Expected reboot/disconnect handling and reconnect check\n\nOut of scope for v1:\n- Hosted firmware distribution/backend\n- Cryptographic signature verification in app\n- Encrypted payload transport mode","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q","is_template":0,"issue_type":"epic","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Implement Universal Shifters BLE DFU v1 in app (manual .bin upload)","updated_at":"2026-03-03T15:37:50Z","waiters":"","wisp_type":"","work_type":""}
|
{"acceptance_criteria":"- User can select a firmware .bin and complete upload end-to-end\n- Upload follows protocol and handles packet loss via retransmit\n- App shows clear progress and failure states\n- Device reconnects after reboot and is reachable","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"c3bd8fa9b5b9d51b04cde5e11c2d8cdedaca29e049a64e8521172a69093b4ba3","created_at":"2026-03-03T15:37:50Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement the firmware update flow defined in universal-shifters/update-process.md for the Flutter app.\n\nScope:\n- Manual local .bin selection and upload over BLE GATT\n- START/DATA/FINISH/ABORT protocol support\n- Cumulative ACK handling with retransmit\n- Expected reboot/disconnect handling and reconnect check\n\nOut of scope for v1:\n- Hosted firmware distribution/backend\n- Cryptographic signature verification in app\n- Encrypted payload transport mode","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q","is_template":0,"issue_type":"epic","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Implement Universal Shifters BLE DFU v1 in app (manual .bin upload)","updated_at":"2026-03-03T15:37:50Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
{"acceptance_criteria":"- All protocol constants match spec exactly\n- No duplicated literal DFU UUID/opcode values across services/UI\n- Domain models compile and are ready for transfer engine integration","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","closed_at":"2026-03-03T15:45:20Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"d471157a1af6199f0ac0a1565b05b28d1a477e66d9166a3865edb42ea1e8c2ae","created_at":"2026-03-03T15:38:34Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add protocol surface in app code for DFU support.\n\nWork:\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\n- Add flags constants and typed update-state/progress models used by service/UI\n- Remove future magic numbers by centralizing constants","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.1","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add DFU protocol constants and domain models","updated_at":"2026-03-03T15:45:20Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
{"acceptance_criteria":"- CRC test vector passes: \"123456789\" =\u003e 0xCBF43926\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\n- Frame segmentation handles final partial payload correctly\n- Seq wrap and ack+1 rewind helpers covered by tests","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","closed_at":"2026-03-03T15:48:57Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"9ba609e63c9e2f8a4e95de6795dace11c610aa9c13908b5dab7aa6cb83ab58d4","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement pure protocol helpers.\n\nWork:\n- Build START payload (11 bytes, LE fields)\n- Build FINISH/ABORT payloads\n- Build/segment DATA frames (seq + 63-byte payload)\n- Implement ACK/sequence helpers including wrapping behavior\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.2","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Implement DFU packet codec and CRC32 utilities with tests","updated_at":"2026-03-03T15:48:57Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
{"acceptance_criteria":"- UI can run full update flow start-to-finish\n- Progress/state transitions are visible and consistent\n- Conflicting controls are disabled during active transfer\n- Failures and cancellations are clearly shown","actor":"","agent_state":"","assignee":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":"- Tests cover success and critical failure/retry paths\n- Wrap-around and ack rewind behavior is validated\n- Regressions in sequencing/CRC are caught automatically","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"f784095261bfc41eed5544a567f667bb2ba4816433f1d5d6ae21ea4616c7109a","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add targeted tests for protocol and engine behavior.\n\nWork:\n- Unit tests for codec + CRC + sequence helpers\n- Engine tests with mocked BLE ack stream for:\n - happy path\n - dropped frame / stalled ACK and rewind\n - timeout and bounded retry fail\n - cancel/abort cleanup\n- Ensure deterministic tests for wrap-around sequence scenarios","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.5","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Add DFU test suite for happy path, loss, stalls, and cancel","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
{"acceptance_criteria":"- Team can execute update and triage failures from docs\n- v1 limitations are explicit and not ambiguous\n- QA checklist is actionable and complete","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"3a5dd3fd4d901ae53a80b3c7b488b41db5e09f7cc50da0e6c185c075b8ad7e51","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Document how to use and support the new updater.\n\nWork:\n- Add app-side DFU flow docs (select/start/progress/reboot/reconnect)\n- Add troubleshooting matrix for common failures (MTU, stalled ACK, reconnect timeout, CRC mismatch)\n- Record explicit v1 limitations and future security/version-verification roadmap\n- Add manual QA checklist for release validation","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.6","is_template":0,"issue_type":"chore","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Document DFU v1 operator flow, troubleshooting, and constraints","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
|
||||||
|
{"acceptance_criteria":"- Happy path reaches done with full ACKed transfer\n- Loss/stall path retransmits and recovers correctly\n- Cancel triggers ABORT and returns to idle cleanly\n- Engine surfaces explicit error reasons for UI","actor":"","agent_state":"","assignee":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":"- 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":""}
|
||||||
|
|||||||
@ -299,11 +299,20 @@ class BluetoothController {
|
|||||||
|
|
||||||
Future<Result<void>> requestMtu(String deviceId,
|
Future<Result<void>> requestMtu(String deviceId,
|
||||||
{int mtu = defaultMtu}) async {
|
{int mtu = defaultMtu}) async {
|
||||||
|
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||||
|
if (result.isErr()) {
|
||||||
|
return bail(result.unwrapErr());
|
||||||
|
}
|
||||||
|
return Ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<int>> requestMtuAndGetValue(String deviceId,
|
||||||
|
{int mtu = defaultMtu}) async {
|
||||||
try {
|
try {
|
||||||
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
||||||
log.info(
|
log.info(
|
||||||
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
||||||
return Ok(null);
|
return Ok(negotiatedMtu);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return bail('Error requesting MTU $mtu for $deviceId: $e');
|
return bail('Error requesting MTU $mtu for $deviceId: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,14 +10,162 @@ const String universalShifterCommandCharacteristicUuid =
|
|||||||
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
'0993826f-0ee4-4b37-9614-d13ecba40005';
|
||||||
const String universalShifterGearRatiosCharacteristicUuid =
|
const String universalShifterGearRatiosCharacteristicUuid =
|
||||||
'0993826f-0ee4-4b37-9614-d13ecba40006';
|
'0993826f-0ee4-4b37-9614-d13ecba40006';
|
||||||
|
const String universalShifterDfuControlCharacteristicUuid =
|
||||||
|
'0993826f-0ee4-4b37-9614-d13ecba40008';
|
||||||
|
const String universalShifterDfuDataCharacteristicUuid =
|
||||||
|
'0993826f-0ee4-4b37-9614-d13ecba40009';
|
||||||
|
const String universalShifterDfuAckCharacteristicUuid =
|
||||||
|
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||||
|
|
||||||
|
const int universalShifterDfuOpcodeStart = 0x01;
|
||||||
|
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||||
|
const int universalShifterDfuOpcodeAbort = 0x03;
|
||||||
|
|
||||||
|
const int universalShifterDfuFrameSizeBytes = 64;
|
||||||
|
const int universalShifterDfuFramePayloadSizeBytes = 63;
|
||||||
|
const int universalShifterAttWriteOverheadBytes = 3;
|
||||||
|
const int universalShifterDfuMinimumMtu =
|
||||||
|
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
|
||||||
|
const int universalShifterDfuPreferredMtu = 128;
|
||||||
|
|
||||||
|
const int universalShifterDfuFlagEncrypted = 0x01;
|
||||||
|
const int universalShifterDfuFlagSigned = 0x02;
|
||||||
|
|
||||||
const int errorSequence = 1;
|
const int errorSequence = 1;
|
||||||
const int errorFtmsMissing = 2;
|
const int errorFtmsMissing = 2;
|
||||||
const int errorPairingAuth = 3;
|
const int errorPairingAuth = 3;
|
||||||
const int errorPairingEncrypt = 4;
|
const int errorPairingEncrypt = 4;
|
||||||
const int errorFtmsRequiredCharMissing = 5;
|
const int errorFtmsRequiredCharMissing = 5;
|
||||||
|
|
||||||
|
enum DfuUpdateState {
|
||||||
|
idle,
|
||||||
|
starting,
|
||||||
|
waitingForAck,
|
||||||
|
transferring,
|
||||||
|
finishing,
|
||||||
|
completed,
|
||||||
|
aborted,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DfuUpdateFlags {
|
||||||
|
const DfuUpdateFlags({
|
||||||
|
this.encrypted = false,
|
||||||
|
this.signed = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool encrypted;
|
||||||
|
final bool signed;
|
||||||
|
|
||||||
|
int get rawValue {
|
||||||
|
var value = 0;
|
||||||
|
if (encrypted) {
|
||||||
|
value |= universalShifterDfuFlagEncrypted;
|
||||||
|
}
|
||||||
|
if (signed) {
|
||||||
|
value |= universalShifterDfuFlagSigned;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DfuUpdateFlags fromRaw(int rawValue) {
|
||||||
|
return DfuUpdateFlags(
|
||||||
|
encrypted: (rawValue & universalShifterDfuFlagEncrypted) != 0,
|
||||||
|
signed: (rawValue & universalShifterDfuFlagSigned) != 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DfuUpdateProgress {
|
||||||
|
const DfuUpdateProgress({
|
||||||
|
required this.state,
|
||||||
|
required this.totalBytes,
|
||||||
|
required this.sentBytes,
|
||||||
|
required this.lastAckedSequence,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.flags,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DfuUpdateState state;
|
||||||
|
final int totalBytes;
|
||||||
|
final int sentBytes;
|
||||||
|
final int lastAckedSequence;
|
||||||
|
final int sessionId;
|
||||||
|
final DfuUpdateFlags flags;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
double get fractionComplete {
|
||||||
|
if (totalBytes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return sentBytes.clamp(0, totalBytes) / totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get percentComplete => (fractionComplete * 100).round();
|
||||||
|
|
||||||
|
bool get isTerminal =>
|
||||||
|
state == DfuUpdateState.completed ||
|
||||||
|
state == DfuUpdateState.aborted ||
|
||||||
|
state == DfuUpdateState.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DfuPreflightFailureReason {
|
||||||
|
deviceNotConnected,
|
||||||
|
wrongConnectedDevice,
|
||||||
|
mtuRequestFailed,
|
||||||
|
mtuTooLow,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DfuPreflightResult {
|
||||||
|
const DfuPreflightResult._({
|
||||||
|
required this.requestedMtu,
|
||||||
|
required this.requiredMtu,
|
||||||
|
required this.negotiatedMtu,
|
||||||
|
required this.failureReason,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int requestedMtu;
|
||||||
|
final int requiredMtu;
|
||||||
|
final int? negotiatedMtu;
|
||||||
|
final DfuPreflightFailureReason? failureReason;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
bool get canStart => failureReason == null;
|
||||||
|
|
||||||
|
static DfuPreflightResult ready({
|
||||||
|
required int requestedMtu,
|
||||||
|
required int negotiatedMtu,
|
||||||
|
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||||
|
}) {
|
||||||
|
return DfuPreflightResult._(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
requiredMtu: requiredMtu,
|
||||||
|
negotiatedMtu: negotiatedMtu,
|
||||||
|
failureReason: null,
|
||||||
|
message: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DfuPreflightResult failed({
|
||||||
|
required int requestedMtu,
|
||||||
|
required DfuPreflightFailureReason failureReason,
|
||||||
|
required String message,
|
||||||
|
int requiredMtu = universalShifterDfuMinimumMtu,
|
||||||
|
int? negotiatedMtu,
|
||||||
|
}) {
|
||||||
|
return DfuPreflightResult._(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
requiredMtu: requiredMtu,
|
||||||
|
negotiatedMtu: negotiatedMtu,
|
||||||
|
failureReason: failureReason,
|
||||||
|
message: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ShifterErrorInfo {
|
class ShifterErrorInfo {
|
||||||
const ShifterErrorInfo({
|
const ShifterErrorInfo({
|
||||||
required this.code,
|
required this.code,
|
||||||
|
|||||||
137
lib/service/dfu_protocol.dart
Normal file
137
lib/service/dfu_protocol.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
|
|
||||||
|
const int _startPayloadLength = 11;
|
||||||
|
|
||||||
|
class DfuStartPayload {
|
||||||
|
const DfuStartPayload({
|
||||||
|
required this.totalLength,
|
||||||
|
required this.imageCrc32,
|
||||||
|
required this.sessionId,
|
||||||
|
required this.flags,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int totalLength;
|
||||||
|
final int imageCrc32;
|
||||||
|
final int sessionId;
|
||||||
|
final int flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DfuDataFrame {
|
||||||
|
const DfuDataFrame({
|
||||||
|
required this.sequence,
|
||||||
|
required this.offset,
|
||||||
|
required this.payloadLength,
|
||||||
|
required this.bytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int sequence;
|
||||||
|
final int offset;
|
||||||
|
final int payloadLength;
|
||||||
|
final Uint8List bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DfuProtocol {
|
||||||
|
const DfuProtocol._();
|
||||||
|
|
||||||
|
static Uint8List encodeStartPayload(DfuStartPayload payload) {
|
||||||
|
final data = ByteData(_startPayloadLength);
|
||||||
|
data.setUint8(0, universalShifterDfuOpcodeStart);
|
||||||
|
data.setUint32(1, payload.totalLength, Endian.little);
|
||||||
|
data.setUint32(5, payload.imageCrc32, Endian.little);
|
||||||
|
data.setUint8(9, payload.sessionId);
|
||||||
|
data.setUint8(10, payload.flags);
|
||||||
|
return data.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List encodeFinishPayload() {
|
||||||
|
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List encodeAbortPayload() {
|
||||||
|
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<DfuDataFrame> buildDataFrames(
|
||||||
|
List<int> imageBytes, {
|
||||||
|
int startSequence = 0,
|
||||||
|
}) {
|
||||||
|
final frames = <DfuDataFrame>[];
|
||||||
|
var seq = _asU8(startSequence);
|
||||||
|
var offset = 0;
|
||||||
|
while (offset < imageBytes.length) {
|
||||||
|
final remaining = imageBytes.length - offset;
|
||||||
|
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
|
||||||
|
? remaining
|
||||||
|
: universalShifterDfuFramePayloadSizeBytes;
|
||||||
|
|
||||||
|
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
|
||||||
|
frame[0] = seq;
|
||||||
|
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
|
||||||
|
|
||||||
|
frames.add(
|
||||||
|
DfuDataFrame(
|
||||||
|
sequence: seq,
|
||||||
|
offset: offset,
|
||||||
|
payloadLength: chunkLength,
|
||||||
|
bytes: frame,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
offset += chunkLength;
|
||||||
|
seq = nextSequence(seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int nextSequence(int sequence) {
|
||||||
|
return _asU8(sequence + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int rewindSequenceFromAck(int acknowledgedSequence) {
|
||||||
|
return nextSequence(acknowledgedSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int sequenceDistance(int from, int to) {
|
||||||
|
return _asU8(to - from);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parseAckPayload(List<int> payload) {
|
||||||
|
if (payload.length != 1) {
|
||||||
|
throw const FormatException('ACK payload must be exactly 1 byte.');
|
||||||
|
}
|
||||||
|
return _asU8(payload.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const int crc32Initial = 0xFFFFFFFF;
|
||||||
|
static const int _crc32PolynomialReflected = 0xEDB88320;
|
||||||
|
|
||||||
|
static int crc32Update(int crc, List<int> bytes) {
|
||||||
|
var next = crc & 0xFFFFFFFF;
|
||||||
|
for (final byte in bytes) {
|
||||||
|
next ^= byte;
|
||||||
|
for (var bit = 0; bit < 8; bit++) {
|
||||||
|
if ((next & 0x1) != 0) {
|
||||||
|
next = (next >> 1) ^ _crc32PolynomialReflected;
|
||||||
|
} else {
|
||||||
|
next >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int crc32Finalize(int crc) {
|
||||||
|
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int crc32(List<int> bytes) {
|
||||||
|
return crc32Finalize(crc32Update(crc32Initial, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _asU8(int value) {
|
||||||
|
return value & 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,30 @@ import 'package:anyhow/anyhow.dart';
|
|||||||
|
|
||||||
class ShifterService {
|
class ShifterService {
|
||||||
ShifterService({
|
ShifterService({
|
||||||
required BluetoothController bluetooth,
|
BluetoothController? bluetooth,
|
||||||
required this.buttonDeviceId,
|
required this.buttonDeviceId,
|
||||||
}) : _bluetooth = bluetooth;
|
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
|
||||||
|
}) : _bluetooth = bluetooth,
|
||||||
|
_dfuPreflightBluetooth =
|
||||||
|
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
|
||||||
|
if (bluetooth == null && dfuPreflightBluetooth == null) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Either bluetooth or dfuPreflightBluetooth must be provided.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final BluetoothController _bluetooth;
|
final BluetoothController? _bluetooth;
|
||||||
final String buttonDeviceId;
|
final String buttonDeviceId;
|
||||||
|
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
|
||||||
|
|
||||||
|
BluetoothController get _requireBluetooth {
|
||||||
|
final bluetooth = _bluetooth;
|
||||||
|
if (bluetooth == null) {
|
||||||
|
throw StateError('Bluetooth controller is not available.');
|
||||||
|
}
|
||||||
|
return bluetooth;
|
||||||
|
}
|
||||||
|
|
||||||
final StreamController<CentralStatus> _statusController =
|
final StreamController<CentralStatus> _statusController =
|
||||||
StreamController<CentralStatus>.broadcast();
|
StreamController<CentralStatus>.broadcast();
|
||||||
@ -28,7 +46,7 @@ class ShifterService {
|
|||||||
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
|
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
|
||||||
try {
|
try {
|
||||||
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
|
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
|
||||||
return _bluetooth.writeCharacteristic(
|
return _requireBluetooth.writeCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
universalShifterConnectToAddrCharacteristicUuid,
|
universalShifterConnectToAddrCharacteristicUuid,
|
||||||
@ -42,7 +60,7 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
|
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
|
||||||
return _bluetooth.writeCharacteristic(
|
return _requireBluetooth.writeCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
universalShifterCommandCharacteristicUuid,
|
universalShifterCommandCharacteristicUuid,
|
||||||
@ -59,7 +77,7 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<GearRatiosData>> readGearRatios() async {
|
Future<Result<GearRatiosData>> readGearRatios() async {
|
||||||
final readRes = await _bluetooth.readCharacteristic(
|
final readRes = await _requireBluetooth.readCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
universalShifterGearRatiosCharacteristicUuid,
|
universalShifterGearRatiosCharacteristicUuid,
|
||||||
@ -106,8 +124,10 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
|
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
|
||||||
final mtuResult =
|
final mtuResult = await _requireBluetooth.requestMtu(
|
||||||
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
|
buttonDeviceId,
|
||||||
|
mtu: _gearRatioWriteMtu,
|
||||||
|
);
|
||||||
if (mtuResult.isErr()) {
|
if (mtuResult.isErr()) {
|
||||||
return bail(
|
return bail(
|
||||||
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
|
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
|
||||||
@ -124,7 +144,7 @@ class ShifterService {
|
|||||||
payload[_defaultGearIndexOffset] =
|
payload[_defaultGearIndexOffset] =
|
||||||
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
|
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
|
||||||
|
|
||||||
return _bluetooth.writeCharacteristic(
|
return _requireBluetooth.writeCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
universalShifterGearRatiosCharacteristicUuid,
|
universalShifterGearRatiosCharacteristicUuid,
|
||||||
@ -133,7 +153,7 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<CentralStatus>> readStatus() async {
|
Future<Result<CentralStatus>> readStatus() async {
|
||||||
final readRes = await _bluetooth.readCharacteristic(
|
final readRes = await _requireBluetooth.readCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
universalShifterStatusCharacteristicUuid,
|
universalShifterStatusCharacteristicUuid,
|
||||||
@ -149,12 +169,78 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<DfuPreflightResult>> runDfuPreflight({
|
||||||
|
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||||
|
}) async {
|
||||||
|
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
|
||||||
|
final connectionStatus = currentConnection.$1;
|
||||||
|
final connectedDeviceId = currentConnection.$2;
|
||||||
|
|
||||||
|
if (connectionStatus != ConnectionStatus.connected ||
|
||||||
|
connectedDeviceId == null) {
|
||||||
|
return Ok(
|
||||||
|
DfuPreflightResult.failed(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
failureReason: DfuPreflightFailureReason.deviceNotConnected,
|
||||||
|
message:
|
||||||
|
'No button connection is active. Connect the target button, then retry the firmware update.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectedDeviceId != buttonDeviceId) {
|
||||||
|
return Ok(
|
||||||
|
DfuPreflightResult.failed(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
|
||||||
|
message:
|
||||||
|
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
|
||||||
|
buttonDeviceId,
|
||||||
|
mtu: requestedMtu,
|
||||||
|
);
|
||||||
|
if (mtuResult.isErr()) {
|
||||||
|
return Ok(
|
||||||
|
DfuPreflightResult.failed(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
|
||||||
|
message:
|
||||||
|
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final negotiatedMtu = mtuResult.unwrap();
|
||||||
|
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
|
||||||
|
return Ok(
|
||||||
|
DfuPreflightResult.failed(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
negotiatedMtu: negotiatedMtu,
|
||||||
|
failureReason: DfuPreflightFailureReason.mtuTooLow,
|
||||||
|
message:
|
||||||
|
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
DfuPreflightResult.ready(
|
||||||
|
requestedMtu: requestedMtu,
|
||||||
|
negotiatedMtu: negotiatedMtu,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void startStatusNotifications() {
|
void startStatusNotifications() {
|
||||||
if (_statusSubscription != null) {
|
if (_statusSubscription != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_statusSubscription = _bluetooth
|
_statusSubscription = _requireBluetooth
|
||||||
.subscribeToCharacteristic(
|
.subscribeToCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
@ -202,6 +288,32 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract interface class DfuPreflightBluetoothAdapter {
|
||||||
|
(ConnectionStatus, String?) get currentConnectionState;
|
||||||
|
Future<Result<int>> requestMtuAndGetValue(
|
||||||
|
String deviceId, {
|
||||||
|
required int mtu,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
|
||||||
|
const _BluetoothDfuPreflightAdapter(this._bluetooth);
|
||||||
|
|
||||||
|
final BluetoothController _bluetooth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
(ConnectionStatus, String?) get currentConnectionState =>
|
||||||
|
_bluetooth.currentConnectionState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<int>> requestMtuAndGetValue(
|
||||||
|
String deviceId, {
|
||||||
|
required int mtu,
|
||||||
|
}) {
|
||||||
|
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class GearRatiosData {
|
class GearRatiosData {
|
||||||
const GearRatiosData({
|
const GearRatiosData({
|
||||||
required this.ratios,
|
required this.ratios,
|
||||||
|
|||||||
137
test/service/dfu_preflight_test.dart
Normal file
137
test/service/dfu_preflight_test.dart
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
|
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||||
|
import 'package:anyhow/anyhow.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ShifterService.runDfuPreflight', () {
|
||||||
|
test('fails when no active button connection exists', () async {
|
||||||
|
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||||
|
currentConnectionState: (ConnectionStatus.disconnected, null),
|
||||||
|
mtuResult: Ok(128),
|
||||||
|
);
|
||||||
|
final service = ShifterService(
|
||||||
|
buttonDeviceId: 'target-device',
|
||||||
|
dfuPreflightBluetooth: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.runDfuPreflight();
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
final preflight = result.unwrap();
|
||||||
|
expect(preflight.canStart, isFalse);
|
||||||
|
expect(preflight.failureReason,
|
||||||
|
DfuPreflightFailureReason.deviceNotConnected);
|
||||||
|
expect(adapter.requestMtuCallCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when connected to a different button', () async {
|
||||||
|
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||||
|
currentConnectionState: (ConnectionStatus.connected, 'wrong-device'),
|
||||||
|
mtuResult: Ok(128),
|
||||||
|
);
|
||||||
|
final service = ShifterService(
|
||||||
|
buttonDeviceId: 'target-device',
|
||||||
|
dfuPreflightBluetooth: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.runDfuPreflight();
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
final preflight = result.unwrap();
|
||||||
|
expect(preflight.canStart, isFalse);
|
||||||
|
expect(preflight.failureReason,
|
||||||
|
DfuPreflightFailureReason.wrongConnectedDevice);
|
||||||
|
expect(adapter.requestMtuCallCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when MTU negotiation fails', () async {
|
||||||
|
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||||
|
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||||
|
mtuResult: bail('adapter rejected mtu request'),
|
||||||
|
);
|
||||||
|
final service = ShifterService(
|
||||||
|
buttonDeviceId: 'target-device',
|
||||||
|
dfuPreflightBluetooth: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.runDfuPreflight(requestedMtu: 247);
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
final preflight = result.unwrap();
|
||||||
|
expect(preflight.canStart, isFalse);
|
||||||
|
expect(
|
||||||
|
preflight.failureReason, DfuPreflightFailureReason.mtuRequestFailed);
|
||||||
|
expect(preflight.message, contains('adapter rejected mtu request'));
|
||||||
|
expect(adapter.requestedMtuValues, [247]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails when negotiated MTU is too low for 64-byte frame writes',
|
||||||
|
() async {
|
||||||
|
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||||
|
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||||
|
mtuResult: Ok(universalShifterDfuMinimumMtu - 1),
|
||||||
|
);
|
||||||
|
final service = ShifterService(
|
||||||
|
buttonDeviceId: 'target-device',
|
||||||
|
dfuPreflightBluetooth: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.runDfuPreflight();
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
final preflight = result.unwrap();
|
||||||
|
expect(preflight.canStart, isFalse);
|
||||||
|
expect(preflight.failureReason, DfuPreflightFailureReason.mtuTooLow);
|
||||||
|
expect(preflight.negotiatedMtu, universalShifterDfuMinimumMtu - 1);
|
||||||
|
expect(preflight.requiredMtu, universalShifterDfuMinimumMtu);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes when connected to target and MTU is sufficient', () async {
|
||||||
|
final adapter = _FakeDfuPreflightBluetoothAdapter(
|
||||||
|
currentConnectionState: (ConnectionStatus.connected, 'target-device'),
|
||||||
|
mtuResult: Ok(128),
|
||||||
|
);
|
||||||
|
final service = ShifterService(
|
||||||
|
buttonDeviceId: 'target-device',
|
||||||
|
dfuPreflightBluetooth: adapter,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.runDfuPreflight();
|
||||||
|
|
||||||
|
expect(result.isOk(), isTrue);
|
||||||
|
final preflight = result.unwrap();
|
||||||
|
expect(preflight.canStart, isTrue);
|
||||||
|
expect(preflight.failureReason, isNull);
|
||||||
|
expect(preflight.negotiatedMtu, 128);
|
||||||
|
expect(preflight.requestedMtu, universalShifterDfuPreferredMtu);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeDfuPreflightBluetoothAdapter
|
||||||
|
implements DfuPreflightBluetoothAdapter {
|
||||||
|
_FakeDfuPreflightBluetoothAdapter({
|
||||||
|
required this.currentConnectionState,
|
||||||
|
required Result<int> mtuResult,
|
||||||
|
}) : _mtuResult = mtuResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final (ConnectionStatus, String?) currentConnectionState;
|
||||||
|
|
||||||
|
final Result<int> _mtuResult;
|
||||||
|
|
||||||
|
int requestMtuCallCount = 0;
|
||||||
|
final List<int> requestedMtuValues = <int>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<int>> requestMtuAndGetValue(
|
||||||
|
String deviceId, {
|
||||||
|
required int mtu,
|
||||||
|
}) async {
|
||||||
|
requestMtuCallCount += 1;
|
||||||
|
requestedMtuValues.add(mtu);
|
||||||
|
return _mtuResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
test/service/dfu_protocol_test.dart
Normal file
86
test/service/dfu_protocol_test.dart
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
|
import 'package:abawo_bt_app/service/dfu_protocol.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('DfuProtocol CRC32', () {
|
||||||
|
test('matches known vector', () {
|
||||||
|
final crc = DfuProtocol.crc32('123456789'.codeUnits);
|
||||||
|
expect(crc, 0xCBF43926);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DfuProtocol control payload encoding', () {
|
||||||
|
test('encodes START payload with exact 11-byte LE layout', () {
|
||||||
|
final payload = DfuProtocol.encodeStartPayload(
|
||||||
|
const DfuStartPayload(
|
||||||
|
totalLength: 0x1234,
|
||||||
|
imageCrc32: 0x89ABCDEF,
|
||||||
|
sessionId: 0x22,
|
||||||
|
flags: universalShifterDfuFlagEncrypted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.length, 11);
|
||||||
|
expect(
|
||||||
|
payload,
|
||||||
|
[
|
||||||
|
universalShifterDfuOpcodeStart,
|
||||||
|
0x34,
|
||||||
|
0x12,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0xEF,
|
||||||
|
0xCD,
|
||||||
|
0xAB,
|
||||||
|
0x89,
|
||||||
|
0x22,
|
||||||
|
universalShifterDfuFlagEncrypted,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes FINISH and ABORT payloads as one byte', () {
|
||||||
|
expect(
|
||||||
|
DfuProtocol.encodeFinishPayload(), [universalShifterDfuOpcodeFinish]);
|
||||||
|
expect(
|
||||||
|
DfuProtocol.encodeAbortPayload(), [universalShifterDfuOpcodeAbort]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DfuProtocol data frame building', () {
|
||||||
|
test('builds 64-byte frames and handles final partial payload', () {
|
||||||
|
final image = List<int>.generate(80, (index) => index);
|
||||||
|
final frames = DfuProtocol.buildDataFrames(image);
|
||||||
|
|
||||||
|
expect(frames.length, 2);
|
||||||
|
|
||||||
|
expect(frames[0].sequence, 0);
|
||||||
|
expect(frames[0].offset, 0);
|
||||||
|
expect(frames[0].payloadLength, universalShifterDfuFramePayloadSizeBytes);
|
||||||
|
expect(frames[0].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||||
|
expect(frames[0].bytes.sublist(1, 64), image.sublist(0, 63));
|
||||||
|
|
||||||
|
expect(frames[1].sequence, 1);
|
||||||
|
expect(frames[1].offset, 63);
|
||||||
|
expect(frames[1].payloadLength, 17);
|
||||||
|
expect(frames[1].bytes.length, universalShifterDfuFrameSizeBytes);
|
||||||
|
expect(frames[1].bytes.sublist(1, 18), image.sublist(63, 80));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DfuProtocol sequence and ACK helpers', () {
|
||||||
|
test('wraps sequence values and computes ack+1 rewind', () {
|
||||||
|
expect(DfuProtocol.nextSequence(0x00), 0x01);
|
||||||
|
expect(DfuProtocol.nextSequence(0xFF), 0x00);
|
||||||
|
|
||||||
|
expect(DfuProtocol.rewindSequenceFromAck(0x05), 0x06);
|
||||||
|
expect(DfuProtocol.rewindSequenceFromAck(0xFF), 0x00);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes wrapping sequence distance', () {
|
||||||
|
expect(DfuProtocol.sequenceDistance(250, 2), 8);
|
||||||
|
expect(DfuProtocol.sequenceDistance(1, 1), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user