From f3e569b3e159ef396b342ff22da161f0f25226c5 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 17:52:15 -0800 Subject: [PATCH 01/85] begin work on firmware flow --- src/benlink/protocol/command/message.py | 23 +++++- src/benlink/protocol/command/vm.py | 95 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/benlink/protocol/command/vm.py diff --git a/src/benlink/protocol/command/message.py b/src/benlink/protocol/command/message.py index c0142a3..3fb220e 100644 --- a/src/benlink/protocol/command/message.py +++ b/src/benlink/protocol/command/message.py @@ -29,6 +29,11 @@ ) from .phone_status import SetPhoneStatusBody, SetPhoneStatusReplyBody from .status import GetHtStatusBody, GetHtStatusReplyBody +from .vm import ( + VmControlBody, VmControlReplyBody, + VmConnectBody, VmConnectReplyBody, + VmDisconnectBody, VmDisconnectReplyBody, +) class CommandGroup(IntEnum): @@ -38,10 +43,10 @@ class CommandGroup(IntEnum): class ExtendedCommand(IntEnum): UNKNOWN = 0 + VM_CONNECT = 1600 + VM_DISCONNECT = 1601 + VM_CONTROL = 1602 GET_BT_SIGNAL = 769 - UNKNOWN_01 = 1600 - UNKNOWN_02 = 1601 - UNKNOWN_03 = 1602 UNKNOWN_04 = 16385 UNKNOWN_05 = 16386 GET_DEV_STATE_VAR = 16387 @@ -183,6 +188,12 @@ def body_disc(m: Message, n: int): return bf_bytes(n // 8) case CommandGroup.EXTENDED: match m.command: + case ExtendedCommand.VM_CONTROL: + out = VmControlReplyBody if m.is_reply else VmControlBody + case ExtendedCommand.VM_CONNECT: + out = VmConnectReplyBody if m.is_reply else VmConnectBody + case ExtendedCommand.VM_DISCONNECT: + out = VmDisconnectReplyBody if m.is_reply else VmDisconnectBody case _: return bf_bytes(n // 8) @@ -216,6 +227,12 @@ def body_disc(m: Message, n: int): SetPhoneStatusReplyBody, GetHtStatusBody, GetHtStatusReplyBody, + VmControlBody, + VmControlReplyBody, + VmConnectBody, + VmConnectReplyBody, + VmDisconnectBody, + VmDisconnectReplyBody, ] diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py new file mode 100644 index 0000000..dc46d86 --- /dev/null +++ b/src/benlink/protocol/command/vm.py @@ -0,0 +1,95 @@ +from __future__ import annotations +from .bitfield import Bitfield, bf_int_enum, bf_int, bf_bytes, bf_dyn, bf_map, bf_bitfield +from .common import ReplyStatus +from enum import IntEnum + +# Order of events in a firmware update: +# 1. VM_CONNECTION +# 2. VM_CONTROL: +# a. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) +# b. UPDATE_START_REQ +# c. UPDATE_DATA_START_REQ +# d. UPDATE_DATA (repeat until all data is sent) +# e. UPDATE_DATA (final fragment with is_final_fragment=True) +# f. UPDATE_IS_VALIDATION_DONE_REQ +# g. UPDATE_TRANSFER_COMPLETE_RES +# Reboot? +# 3. VM_CONNECT +# h. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) +# i. UPDATE_START_REQ +# j. UPDATE_IN_PROGRESS_RES +# 4. VM_DISCONNECT + + +class VmControlType(IntEnum): + # Regular firmware update flow + UPDATE_SYNC_REQ = 19 + UPDATE_START_REQ = 1 + UPDATE_DATA_START_REQ = 21 + UPDATE_DATA = 4 + UPDATE_IS_VALIDATION_DONE_REQ = 22 + UPDATE_TRANSFER_COMPLETE_RES = 12 + UPDATE_IN_PROGRESS_RES = 14 + UPDATE_ABORT_REQ = 7 + + # This looks like a fancy way of aborting when + # you get an error code in the update process + # looks like you always just send one after the other + # with the same error code? + UPDATE_ABORT_REQ_WITH_CODE1 = 31 + UPDATE_ABORT_REQ_WITH_CODE2 = 32 + + # Not used in regular firmware update? + # It seems like there's a hidden debug firmware GUI + # in the app somewhere that can send these commands + UPDATE_COMMIT_CFM = 16 + UPDATE_ERASE_SQIF_CFM = 30 + + +class BoolTransform: + def forward(self, x: int) -> bool: + return bool(x) + + def back(self, y: bool) -> int: + return int(y) + + +class VmControlUpdateData(Bitfield): + is_final_fragment: bool = bf_map(bf_int(8), BoolTransform()) + data: bytes = bf_dyn(lambda _, n: bf_bytes(n // 8)) + + +def vm_control_disc(m: VmControlBody): + match m.vm_control_type: + case VmControlType.UPDATE_DATA: + out = VmControlUpdateData + case _: + return bf_bytes(m.n_bytes_payload) + + return bf_bitfield(out, m.n_bytes_payload*8) + + +class VmControlBody(Bitfield): + vm_control_type: int = bf_int_enum(VmControlType, 8) + n_bytes_payload: int = bf_int(16) + data: VmControlUpdateData | bytes = bf_dyn(vm_control_disc) + + +class VmControlReplyBody(Bitfield): + status: ReplyStatus = bf_int_enum(ReplyStatus, 8) + + +class VmConnectBody(Bitfield): + pass + + +class VmConnectReplyBody(Bitfield): + status: ReplyStatus = bf_int_enum(ReplyStatus, 8) + + +class VmDisconnectBody(Bitfield): + pass + + +class VmDisconnectReplyBody(Bitfield): + status: ReplyStatus = bf_int_enum(ReplyStatus, 8) From 88b09c1abd13f6614e98a2d4837d65e7cc284f81 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 18:19:01 -0800 Subject: [PATCH 02/85] allow unknown versions of phone_status --- src/benlink/protocol/command/phone_status.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/benlink/protocol/command/phone_status.py b/src/benlink/protocol/command/phone_status.py index 3f771f5..d94a3f3 100644 --- a/src/benlink/protocol/command/phone_status.py +++ b/src/benlink/protocol/command/phone_status.py @@ -1,10 +1,10 @@ from __future__ import annotations -from .bitfield import Bitfield, bf_lit_int, bf_int_enum, bf_list, bf_bool +from .bitfield import Bitfield, bf_lit_int, bf_int_enum, bf_list, bf_bool, bf_dyn, bf_bytes import typing as t from .common import ReplyStatus -class SetPhoneStatusBody(Bitfield): +class PhoneStatus(Bitfield): is_channel_bonded_lower: t.List[bool] = bf_list(bf_bool(), 16) is_linked: bool _pad: t.Literal[0] = bf_lit_int(1, default=0) @@ -12,5 +12,18 @@ class SetPhoneStatusBody(Bitfield): _pad2: t.Literal[0] = bf_lit_int(14, default=0) +def phone_status_disc(_: SetPhoneStatusBody, n: int): + if n == PhoneStatus.length(): + return PhoneStatus + + # TODO: There's a 32 bit version of phone status that popped up in + # uv-pro 0.7.9-32 upgrade firmware. I'll need to see what it is... + return bf_bytes(n // 8) + + +class SetPhoneStatusBody(Bitfield): + phone_status: PhoneStatus | bytes = bf_dyn(phone_status_disc) + + class SetPhoneStatusReplyBody(Bitfield): reply_status: ReplyStatus = bf_int_enum(ReplyStatus, 8) From 2a5a78b4684e86cc15104cacac3dab72ce3af2c1 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 18:32:52 -0800 Subject: [PATCH 03/85] add notes for abort flow --- src/benlink/protocol/command/vm.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index dc46d86..b1ed511 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -4,6 +4,7 @@ from enum import IntEnum # Order of events in a firmware update: +# # 1. VM_CONNECTION # 2. VM_CONTROL: # a. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) @@ -12,14 +13,27 @@ # d. UPDATE_DATA (repeat until all data is sent) # e. UPDATE_DATA (final fragment with is_final_fragment=True) # f. UPDATE_IS_VALIDATION_DONE_REQ -# g. UPDATE_TRANSFER_COMPLETE_RES -# Reboot? +# g. UPDATE_TRANSFER_COMPLETE_RES (triggers reboot?) +# +# Reboot happens? +# # 3. VM_CONNECT # h. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) # i. UPDATE_START_REQ # j. UPDATE_IN_PROGRESS_RES # 4. VM_DISCONNECT +# Order of events in an aborted firmware update: +# +# 1. VM_CONNECTION +# 2. VM_CONTROL: +# a. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) +# b. UPDATE_START_REQ +# c. UPDATE_DATA_START_REQ +# d. UPDATE_DATA (repeat until all data is sent) +# e. UPDATE_ABORT_REQ +# 3. VM_DISCONNECT + class VmControlType(IntEnum): # Regular firmware update flow From 05a3b65720121c161fb54dda6b273496072ebb9f Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 18:35:46 -0800 Subject: [PATCH 04/85] update enum names --- src/benlink/protocol/command/vm.py | 44 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index b1ed511..100f307 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -7,57 +7,57 @@ # # 1. VM_CONNECTION # 2. VM_CONTROL: -# a. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) -# b. UPDATE_START_REQ -# c. UPDATE_DATA_START_REQ +# a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) +# b. UPDATE_START +# c. UPDATE_DATA_START # d. UPDATE_DATA (repeat until all data is sent) # e. UPDATE_DATA (final fragment with is_final_fragment=True) -# f. UPDATE_IS_VALIDATION_DONE_REQ -# g. UPDATE_TRANSFER_COMPLETE_RES (triggers reboot?) +# f. UPDATE_IS_VALIDATION_DONE +# g. UPDATE_TRANSFER_COMPLETE (triggers reboot?) # # Reboot happens? # # 3. VM_CONNECT -# h. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) -# i. UPDATE_START_REQ -# j. UPDATE_IN_PROGRESS_RES +# h. UPDATE_SYNC (with last 4 bytes of firmware md5sum) +# i. UPDATE_START +# j. UPDATE_IN_PROGRESS # 4. VM_DISCONNECT # Order of events in an aborted firmware update: # # 1. VM_CONNECTION # 2. VM_CONTROL: -# a. UPDATE_SYNC_REQ (with last 4 bytes of firmware md5sum) -# b. UPDATE_START_REQ -# c. UPDATE_DATA_START_REQ +# a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) +# b. UPDATE_START +# c. UPDATE_DATA_START # d. UPDATE_DATA (repeat until all data is sent) -# e. UPDATE_ABORT_REQ +# e. UPDATE_ABORT # 3. VM_DISCONNECT class VmControlType(IntEnum): # Regular firmware update flow - UPDATE_SYNC_REQ = 19 - UPDATE_START_REQ = 1 - UPDATE_DATA_START_REQ = 21 + UPDATE_SYNC = 19 + UPDATE_START = 1 + UPDATE_DATA_START = 21 UPDATE_DATA = 4 - UPDATE_IS_VALIDATION_DONE_REQ = 22 - UPDATE_TRANSFER_COMPLETE_RES = 12 - UPDATE_IN_PROGRESS_RES = 14 + UPDATE_IS_VALIDATION_DONE = 22 + UPDATE_TRANSFER_COMPLETE = 12 + UPDATE_IN_PROGRESS = 14 UPDATE_ABORT_REQ = 7 # This looks like a fancy way of aborting when # you get an error code in the update process # looks like you always just send one after the other # with the same error code? - UPDATE_ABORT_REQ_WITH_CODE1 = 31 - UPDATE_ABORT_REQ_WITH_CODE2 = 32 + UPDATE_ABORT_WITH_CODE1 = 31 + UPDATE_ABORT_WITH_CODE2 = 32 # Not used in regular firmware update? # It seems like there's a hidden debug firmware GUI # in the app somewhere that can send these commands - UPDATE_COMMIT_CFM = 16 - UPDATE_ERASE_SQIF_CFM = 30 + UPDATE_COMMIT = 16 + UPDATE_ERASE_SQIF = 30 class BoolTransform: From 85d41f5034c60aa81dfd6796a7ec843b4c519d9b Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 18:56:08 -0800 Subject: [PATCH 05/85] add message objects for more vm_control commands --- src/benlink/protocol/command/vm.py | 68 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 100f307..c3f7097 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -1,8 +1,10 @@ from __future__ import annotations -from .bitfield import Bitfield, bf_int_enum, bf_int, bf_bytes, bf_dyn, bf_map, bf_bitfield +import typing as t +from .bitfield import Bitfield, bf_int_enum, bf_int, bf_bytes, bf_dyn, bf_map, bf_bitfield, bf_lit_int from .common import ReplyStatus from enum import IntEnum +##################################################################### # Order of events in a firmware update: # # 1. VM_CONNECTION @@ -10,7 +12,7 @@ # a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) # b. UPDATE_START # c. UPDATE_DATA_START -# d. UPDATE_DATA (repeat until all data is sent) +# d. UPDATE_DATA (145 bytes at a time. repeat until all data is sent, except for the last fragment) # e. UPDATE_DATA (final fragment with is_final_fragment=True) # f. UPDATE_IS_VALIDATION_DONE # g. UPDATE_TRANSFER_COMPLETE (triggers reboot?) @@ -23,6 +25,7 @@ # j. UPDATE_IN_PROGRESS # 4. VM_DISCONNECT +##################################################################### # Order of events in an aborted firmware update: # # 1. VM_CONNECTION @@ -68,25 +71,82 @@ def back(self, y: bool) -> int: return int(y) +bf_bool_byte = bf_map(bf_int(8), BoolTransform()) + + +class VmControlUpdateSync(Bitfield): + md5sum_tail: bytes = bf_bytes(4) + + +class VmControlUpdateStart(Bitfield): + pass + + +class VmControlUpdateDataStart(Bitfield): + pass + + class VmControlUpdateData(Bitfield): - is_final_fragment: bool = bf_map(bf_int(8), BoolTransform()) + is_final_fragment: bool = bf_bool_byte data: bytes = bf_dyn(lambda _, n: bf_bytes(n // 8)) +class VmControlUpdateIsValidationDone(Bitfield): + pass + + +class VmControlUpdateTransferComplete(Bitfield): + is_complete: bool = bf_bool_byte + + +class VmControlUpdateInProgress(Bitfield): + _pad: t.Literal[0] = bf_lit_int(8, default=0) + + +class VmControlUpdateAbortReq(Bitfield): + pass + + def vm_control_disc(m: VmControlBody): match m.vm_control_type: + case VmControlType.UPDATE_SYNC: + out = VmControlUpdateSync + case VmControlType.UPDATE_START: + out = VmControlUpdateStart + case VmControlType.UPDATE_DATA_START: + out = VmControlUpdateDataStart case VmControlType.UPDATE_DATA: out = VmControlUpdateData + case VmControlType.UPDATE_IS_VALIDATION_DONE: + out = VmControlUpdateIsValidationDone + case VmControlType.UPDATE_TRANSFER_COMPLETE: + out = VmControlUpdateTransferComplete + case VmControlType.UPDATE_IN_PROGRESS: + out = VmControlUpdateInProgress + case VmControlType.UPDATE_ABORT_REQ: + out = VmControlUpdateAbortReq case _: return bf_bytes(m.n_bytes_payload) return bf_bitfield(out, m.n_bytes_payload*8) +VmControlCommand = t.Union[ + VmControlUpdateSync, + VmControlUpdateStart, + VmControlUpdateDataStart, + VmControlUpdateData, + VmControlUpdateIsValidationDone, + VmControlUpdateTransferComplete, + VmControlUpdateInProgress, + VmControlUpdateAbortReq, +] + + class VmControlBody(Bitfield): vm_control_type: int = bf_int_enum(VmControlType, 8) n_bytes_payload: int = bf_int(16) - data: VmControlUpdateData | bytes = bf_dyn(vm_control_disc) + command: VmControlCommand | bytes = bf_dyn(vm_control_disc) class VmControlReplyBody(Bitfield): From eaa694cd137478683b914067377e647511a4da87 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 19:30:39 -0800 Subject: [PATCH 06/85] fix label --- src/benlink/protocol/command/vm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index c3f7097..fa91e3f 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -7,7 +7,7 @@ ##################################################################### # Order of events in a firmware update: # -# 1. VM_CONNECTION +# 1. VM_CONNECT # 2. VM_CONTROL: # a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) # b. UPDATE_START @@ -28,7 +28,7 @@ ##################################################################### # Order of events in an aborted firmware update: # -# 1. VM_CONNECTION +# 1. VM_CONNECT # 2. VM_CONTROL: # a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) # b. UPDATE_START From 4919cc9bce772efad6655331fd2c1e3c107cf7a8 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 9 Feb 2025 21:57:58 -0800 Subject: [PATCH 07/85] label unknown commands --- src/benlink/protocol/command/message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/benlink/protocol/command/message.py b/src/benlink/protocol/command/message.py index 3fb220e..d409dbc 100644 --- a/src/benlink/protocol/command/message.py +++ b/src/benlink/protocol/command/message.py @@ -47,9 +47,9 @@ class ExtendedCommand(IntEnum): VM_DISCONNECT = 1601 VM_CONTROL = 1602 GET_BT_SIGNAL = 769 - UNKNOWN_04 = 16385 - UNKNOWN_05 = 16386 - GET_DEV_STATE_VAR = 16387 + REGISTER_BT_NOTIFICATION = 16385 + CANCEL_BT_NOTIFICATION = 16386 + BT_EVENT_NOTIFICATION = 16387 DEV_REGISTRATION = 1825 @classmethod From 3cd8fa5acb0c7fde30e77f8ac77ae844506efecb Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Tue, 11 Feb 2025 17:37:25 -0800 Subject: [PATCH 08/85] add bt_notifications --- .../protocol/command/bt_notification.py | 49 ++++++++++ src/benlink/protocol/command/dev_state_var.py | 28 ------ src/benlink/protocol/command/message.py | 10 ++ src/benlink/protocol/command/vm.py | 94 ++++++++++++------- 4 files changed, 118 insertions(+), 63 deletions(-) create mode 100644 src/benlink/protocol/command/bt_notification.py delete mode 100644 src/benlink/protocol/command/dev_state_var.py diff --git a/src/benlink/protocol/command/bt_notification.py b/src/benlink/protocol/command/bt_notification.py new file mode 100644 index 0000000..4f60cb1 --- /dev/null +++ b/src/benlink/protocol/command/bt_notification.py @@ -0,0 +1,49 @@ +from __future__ import annotations +from .bitfield import Bitfield, bf_int_enum, bf_dyn, bf_bytes, bf_bitfield +from enum import IntEnum +from .vm import VmControlBody + +################################################# +# BT_EVENT_NOTIFICATION + + +class BtEventType(IntEnum): + START = 0 + RSSI_LOW_THRESHOLD = 1 + RSSI_HIGH_THRESHOLD = 2 + BATTERY_LOW_THRESHOLD = 3 + BATTERY_HIGH_THRESHOLD = 4 + DEVICE_STATE_CHANGED = 5 + PIO_CHANGED = 6 + DEBUG_MESSAGE = 7 + BATTERY_CHARGED = 8 + CHARGER_CONNECTION = 9 + CAPSENSE_UPDATE = 10 + USER_ACTION = 11 + SPEECH_RECOGNITION = 12 + AV_COMMAND = 13 + REMOTE_BATTERY_LEVEL = 14 + KEY = 15 + DFU_STATE = 16 + UART_RECEIVED_DATA = 17 + VMU_PACKET = 18 + + +# class BtEventVmuPacket(Bitfield): +# vm_control_type: VmControlType = bf_int_enum(VmControlType, 8) +# control_command: VmControlCommand | bytes = bf_dyn(vm_control_disc) + + +def bt_event_disc(m: BtEventNotificationBody, n: int): + match m.bt_event_type: + case BtEventType.VMU_PACKET: + out = VmControlBody + case _: + return bf_bytes(n // 8) + + return bf_bitfield(out, n) + + +class BtEventNotificationBody(Bitfield): + bt_event_type: BtEventType = bf_int_enum(BtEventType, 8) + data: VmControlBody | bytes = bf_dyn(bt_event_disc) diff --git a/src/benlink/protocol/command/dev_state_var.py b/src/benlink/protocol/command/dev_state_var.py deleted file mode 100644 index 5eed02d..0000000 --- a/src/benlink/protocol/command/dev_state_var.py +++ /dev/null @@ -1,28 +0,0 @@ -from enum import IntEnum - -################################################# -# GET_DEV_STATE_VAR - - -class DevStateVar(IntEnum): - START = 0 - RSSI_LOW_THRESHOLD = 1 - RSSI_HIGH_THRESHOLD = 2 - BATTERY_LOW_THRESHOLD = 3 - BATTERY_HIGH_THRESHOLD = 4 - DEVICE_STATE_CHANGED = 5 - PIO_CHANGED = 6 - DEBUG_MESSAGE = 7 - BATTERY_CHARGED = 8 - CHARGER_CONNECTION = 9 - CAPSENSE_UPDATE = 10 - USER_ACTION = 11 - SPEECH_RECOGNITION = 12 - AV_COMMAND = 13 - REMOTE_BATTERY_LEVEL = 14 - KEY = 15 - DFU_STATE = 16 - UART_RECEIVED_DATA = 17 - VMU_PACKET = 18 - -# TODO diff --git a/src/benlink/protocol/command/message.py b/src/benlink/protocol/command/message.py index d409dbc..082c009 100644 --- a/src/benlink/protocol/command/message.py +++ b/src/benlink/protocol/command/message.py @@ -34,6 +34,9 @@ VmConnectBody, VmConnectReplyBody, VmDisconnectBody, VmDisconnectReplyBody, ) +from .bt_notification import ( + BtEventNotificationBody +) class CommandGroup(IntEnum): @@ -194,6 +197,12 @@ def body_disc(m: Message, n: int): out = VmConnectReplyBody if m.is_reply else VmConnectBody case ExtendedCommand.VM_DISCONNECT: out = VmDisconnectReplyBody if m.is_reply else VmDisconnectBody + case ExtendedCommand.BT_EVENT_NOTIFICATION: + if m.is_reply: + raise ValueError( + "BtEventNotification cannot be a reply" + ) + out = BtEventNotificationBody case _: return bf_bytes(n // 8) @@ -233,6 +242,7 @@ def body_disc(m: Message, n: int): VmConnectReplyBody, VmDisconnectBody, VmDisconnectReplyBody, + BtEventNotificationBody ] diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index fa91e3f..27d82f0 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -40,27 +40,39 @@ class VmControlType(IntEnum): # Regular firmware update flow - UPDATE_SYNC = 19 - UPDATE_START = 1 - UPDATE_DATA_START = 21 + UPDATE_SYNC_REQ = 19 + UPDATE_START_REQ = 1 + UPDATE_START_DATA_REQ = 21 UPDATE_DATA = 4 - UPDATE_IS_VALIDATION_DONE = 22 - UPDATE_TRANSFER_COMPLETE = 12 - UPDATE_IN_PROGRESS = 14 + UPDATE_IS_VALIDATION_DONE_REQ = 22 + UPDATE_TRANSFER_COMPLETE_RES = 12 + UPDATE_IN_PROGRESS_RES = 14 UPDATE_ABORT_REQ = 7 + # Replies via VMU_PACKET + UPDATE_START_CFM = 2 + UPDATE_DATA_BYTES_REQ = 3 + UPDATE_ABORT_CFM = 8 + UPDATE_TRANSFER_COMPLETE_IND = 11 + UPDATE_COMMIT_RES = 15 + UPDATE_SYNC_CFM = 20 + UPDATE_IS_VALIDATION_DONE_CFM = 23 + UPDATE_COMMIT_RES_2 = 29 + VM_UPDATE_ERRORS = 17 + UPDATE_COMPLETE_IND = 18 + # This looks like a fancy way of aborting when # you get an error code in the update process # looks like you always just send one after the other # with the same error code? - UPDATE_ABORT_WITH_CODE1 = 31 - UPDATE_ABORT_WITH_CODE2 = 32 + UPDATE_ABORT_WITH_CODE_1_REQ = 31 + UPDATE_ABORT_WITH_CODE_2_REQ = 32 # Not used in regular firmware update? # It seems like there's a hidden debug firmware GUI # in the app somewhere that can send these commands - UPDATE_COMMIT = 16 - UPDATE_ERASE_SQIF = 30 + UPDATE_COMMIT_CFM = 16 + UPDATE_ERASE_SQIF_CFM = 30 class BoolTransform: @@ -74,15 +86,15 @@ def back(self, y: bool) -> int: bf_bool_byte = bf_map(bf_int(8), BoolTransform()) -class VmControlUpdateSync(Bitfield): +class VmControlUpdateSyncReq(Bitfield): md5sum_tail: bytes = bf_bytes(4) -class VmControlUpdateStart(Bitfield): +class VmControlUpdateStartReq(Bitfield): pass -class VmControlUpdateDataStart(Bitfield): +class VmControlUpdateDataStartReq(Bitfield): pass @@ -91,15 +103,15 @@ class VmControlUpdateData(Bitfield): data: bytes = bf_dyn(lambda _, n: bf_bytes(n // 8)) -class VmControlUpdateIsValidationDone(Bitfield): +class VmControlUpdateIsValidationDoneReq(Bitfield): pass -class VmControlUpdateTransferComplete(Bitfield): +class VmControlUpdateTransferCompleteRes(Bitfield): is_complete: bool = bf_bool_byte -class VmControlUpdateInProgress(Bitfield): +class VmControlUpdateInProgressRes(Bitfield): _pad: t.Literal[0] = bf_lit_int(8, default=0) @@ -107,24 +119,35 @@ class VmControlUpdateAbortReq(Bitfield): pass +# Messages from VMU_PACKET +class VmControlUpdateDataBytesReq(Bitfield): + # The max bytes requested that the HT app allows is 250 + n_bytes_requested: int = bf_int(32) + # Skip allows for resuming a firmware update maybe? + # I don't see it used in any of my logs + n_bytes_skip: int = bf_int(32) + + def vm_control_disc(m: VmControlBody): match m.vm_control_type: - case VmControlType.UPDATE_SYNC: - out = VmControlUpdateSync - case VmControlType.UPDATE_START: - out = VmControlUpdateStart - case VmControlType.UPDATE_DATA_START: - out = VmControlUpdateDataStart + case VmControlType.UPDATE_SYNC_REQ: + out = VmControlUpdateSyncReq + case VmControlType.UPDATE_START_REQ: + out = VmControlUpdateStartReq + case VmControlType.UPDATE_START_DATA_REQ: + out = VmControlUpdateDataStartReq case VmControlType.UPDATE_DATA: out = VmControlUpdateData - case VmControlType.UPDATE_IS_VALIDATION_DONE: - out = VmControlUpdateIsValidationDone - case VmControlType.UPDATE_TRANSFER_COMPLETE: - out = VmControlUpdateTransferComplete - case VmControlType.UPDATE_IN_PROGRESS: - out = VmControlUpdateInProgress + case VmControlType.UPDATE_IS_VALIDATION_DONE_REQ: + out = VmControlUpdateIsValidationDoneReq + case VmControlType.UPDATE_TRANSFER_COMPLETE_RES: + out = VmControlUpdateTransferCompleteRes + case VmControlType.UPDATE_IN_PROGRESS_RES: + out = VmControlUpdateInProgressRes case VmControlType.UPDATE_ABORT_REQ: out = VmControlUpdateAbortReq + case VmControlType.UPDATE_DATA_BYTES_REQ: + out = VmControlUpdateDataBytesReq case _: return bf_bytes(m.n_bytes_payload) @@ -132,19 +155,20 @@ def vm_control_disc(m: VmControlBody): VmControlCommand = t.Union[ - VmControlUpdateSync, - VmControlUpdateStart, - VmControlUpdateDataStart, + VmControlUpdateSyncReq, + VmControlUpdateStartReq, + VmControlUpdateDataStartReq, VmControlUpdateData, - VmControlUpdateIsValidationDone, - VmControlUpdateTransferComplete, - VmControlUpdateInProgress, + VmControlUpdateIsValidationDoneReq, + VmControlUpdateTransferCompleteRes, + VmControlUpdateInProgressRes, VmControlUpdateAbortReq, + VmControlUpdateDataBytesReq, ] class VmControlBody(Bitfield): - vm_control_type: int = bf_int_enum(VmControlType, 8) + vm_control_type: VmControlType = bf_int_enum(VmControlType, 8) n_bytes_payload: int = bf_int(16) command: VmControlCommand | bytes = bf_dyn(vm_control_disc) From 8c0fb4ca22bcb5e810b1387e33551d96778ab3aa Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Tue, 11 Feb 2025 18:56:39 -0800 Subject: [PATCH 09/85] more vm cmds --- src/benlink/protocol/command/vm.py | 79 +++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 27d82f0..969c23c 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -57,8 +57,8 @@ class VmControlType(IntEnum): UPDATE_COMMIT_RES = 15 UPDATE_SYNC_CFM = 20 UPDATE_IS_VALIDATION_DONE_CFM = 23 - UPDATE_COMMIT_RES_2 = 29 - VM_UPDATE_ERRORS = 17 + UPDATE_COMMIT_ERASE_SQIF_RES = 29 + VM_UPDATE_ERROR = 17 UPDATE_COMPLETE_IND = 18 # This looks like a fancy way of aborting when @@ -119,7 +119,56 @@ class VmControlUpdateAbortReq(Bitfield): pass +class UpdateState(IntEnum): + DATA_TRANSFER = 0 + VALIDATION = 1 + TRANSFER_COMPLETE = 2 + IN_PROGRESS = 3 + COMMIT = 4 + GOTO_NEXT_STATE = 9 + + +class UpdateError(IntEnum): + UNKNOWN = 0 + BATTERY_LOW = 33 + SYNC_IS_DIFFERENT = 129 + + @classmethod + def _missing_(cls, value: object): + import sys + print(f"Unknown value for {cls.__name__}: {value}", file=sys.stderr) + return cls.UNKNOWN + # Messages from VMU_PACKET + + +class VmControlUpdateSyncCfm(Bitfield): + update_state: UpdateState = bf_int_enum(UpdateState, 8) + md5sum_tail: bytes = bf_bytes(4) + unknown: bytes = bf_bytes(1) + + +class VmControlUpdateStartCfm(Bitfield): + update_state: UpdateState = bf_int_enum(UpdateState, 8) + unknown: bytes = bf_bytes(2) + + +class VmControlUpdateCompleteInd(Bitfield): + pass + + +class VmControlUpdateTransferCompleteInd(Bitfield): + pass + + +class VmControlUpdateAbortCfm(Bitfield): + pass + + +class VmUpdateError(Bitfield): + update_error: UpdateError = bf_int_enum(UpdateError, 16) + + class VmControlUpdateDataBytesReq(Bitfield): # The max bytes requested that the HT app allows is 250 n_bytes_requested: int = bf_int(32) @@ -128,6 +177,13 @@ class VmControlUpdateDataBytesReq(Bitfield): n_bytes_skip: int = bf_int(32) +# UPDATE_COMMIT_RES = 15 +# UPDATE_COMMIT_RES_2 = 29 # Probably for ERASE_SQIF_CFM? +# UPDATE_SYNC_CFM = 20 +# UPDATE_IS_VALIDATION_DONE_CFM = 23 +# VM_UPDATE_ERROR = 17 +# UPDATE_COMPLETE_IND = 18 + def vm_control_disc(m: VmControlBody): match m.vm_control_type: case VmControlType.UPDATE_SYNC_REQ: @@ -148,6 +204,19 @@ def vm_control_disc(m: VmControlBody): out = VmControlUpdateAbortReq case VmControlType.UPDATE_DATA_BYTES_REQ: out = VmControlUpdateDataBytesReq + case VmControlType.UPDATE_SYNC_CFM: + out = VmControlUpdateSyncCfm + case VmControlType.UPDATE_COMPLETE_IND: + out = VmControlUpdateCompleteInd + case VmControlType.UPDATE_TRANSFER_COMPLETE_IND: + out = VmControlUpdateTransferCompleteInd + case VmControlType.UPDATE_START_CFM: + out = VmControlUpdateStartCfm + case VmControlType.VM_UPDATE_ERROR: + out = VmUpdateError + case VmControlType.UPDATE_ABORT_CFM: + out = VmControlUpdateAbortCfm + case _: return bf_bytes(m.n_bytes_payload) @@ -164,6 +233,12 @@ def vm_control_disc(m: VmControlBody): VmControlUpdateInProgressRes, VmControlUpdateAbortReq, VmControlUpdateDataBytesReq, + VmControlUpdateSyncCfm, + VmControlUpdateCompleteInd, + VmControlUpdateTransferCompleteInd, + VmControlUpdateStartCfm, + VmUpdateError, + VmControlUpdateAbortCfm, ] From eac9eac2a321c30a136121fc7a7e6c18e7d7ee4e Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Tue, 11 Feb 2025 18:59:36 -0800 Subject: [PATCH 10/85] add update error --- src/benlink/protocol/command/vm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 969c23c..40d4913 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -58,7 +58,7 @@ class VmControlType(IntEnum): UPDATE_SYNC_CFM = 20 UPDATE_IS_VALIDATION_DONE_CFM = 23 UPDATE_COMMIT_ERASE_SQIF_RES = 29 - VM_UPDATE_ERROR = 17 + UPDATE_ERROR = 17 UPDATE_COMPLETE_IND = 18 # This looks like a fancy way of aborting when @@ -165,7 +165,7 @@ class VmControlUpdateAbortCfm(Bitfield): pass -class VmUpdateError(Bitfield): +class VmControlUpdateError(Bitfield): update_error: UpdateError = bf_int_enum(UpdateError, 16) @@ -212,8 +212,8 @@ def vm_control_disc(m: VmControlBody): out = VmControlUpdateTransferCompleteInd case VmControlType.UPDATE_START_CFM: out = VmControlUpdateStartCfm - case VmControlType.VM_UPDATE_ERROR: - out = VmUpdateError + case VmControlType.UPDATE_ERROR: + out = VmControlUpdateError case VmControlType.UPDATE_ABORT_CFM: out = VmControlUpdateAbortCfm @@ -237,7 +237,7 @@ def vm_control_disc(m: VmControlBody): VmControlUpdateCompleteInd, VmControlUpdateTransferCompleteInd, VmControlUpdateStartCfm, - VmUpdateError, + VmControlUpdateError, VmControlUpdateAbortCfm, ] From 1ca912f29ba8f9ddde76f62a52db81f6bca5d593 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Tue, 11 Feb 2025 19:13:05 -0800 Subject: [PATCH 11/85] add class vmu packet --- .../protocol/command/bt_notification.py | 11 +-- src/benlink/protocol/command/vm.py | 71 +++++++++++-------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/benlink/protocol/command/bt_notification.py b/src/benlink/protocol/command/bt_notification.py index 4f60cb1..52a1026 100644 --- a/src/benlink/protocol/command/bt_notification.py +++ b/src/benlink/protocol/command/bt_notification.py @@ -1,7 +1,7 @@ from __future__ import annotations from .bitfield import Bitfield, bf_int_enum, bf_dyn, bf_bytes, bf_bitfield from enum import IntEnum -from .vm import VmControlBody +from .vm import VmuPacket ################################################# # BT_EVENT_NOTIFICATION @@ -29,15 +29,10 @@ class BtEventType(IntEnum): VMU_PACKET = 18 -# class BtEventVmuPacket(Bitfield): -# vm_control_type: VmControlType = bf_int_enum(VmControlType, 8) -# control_command: VmControlCommand | bytes = bf_dyn(vm_control_disc) - - def bt_event_disc(m: BtEventNotificationBody, n: int): match m.bt_event_type: case BtEventType.VMU_PACKET: - out = VmControlBody + out = VmuPacket case _: return bf_bytes(n // 8) @@ -46,4 +41,4 @@ def bt_event_disc(m: BtEventNotificationBody, n: int): class BtEventNotificationBody(Bitfield): bt_event_type: BtEventType = bf_int_enum(BtEventType, 8) - data: VmControlBody | bytes = bf_dyn(bt_event_disc) + bt_event: VmuPacket | bytes = bf_dyn(bt_event_disc) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 40d4913..ea948bf 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -39,6 +39,8 @@ class VmControlType(IntEnum): + # Command from the app to the device + # Regular firmware update flow UPDATE_SYNC_REQ = 19 UPDATE_START_REQ = 1 @@ -49,18 +51,6 @@ class VmControlType(IntEnum): UPDATE_IN_PROGRESS_RES = 14 UPDATE_ABORT_REQ = 7 - # Replies via VMU_PACKET - UPDATE_START_CFM = 2 - UPDATE_DATA_BYTES_REQ = 3 - UPDATE_ABORT_CFM = 8 - UPDATE_TRANSFER_COMPLETE_IND = 11 - UPDATE_COMMIT_RES = 15 - UPDATE_SYNC_CFM = 20 - UPDATE_IS_VALIDATION_DONE_CFM = 23 - UPDATE_COMMIT_ERASE_SQIF_RES = 29 - UPDATE_ERROR = 17 - UPDATE_COMPLETE_IND = 18 - # This looks like a fancy way of aborting when # you get an error code in the update process # looks like you always just send one after the other @@ -75,6 +65,20 @@ class VmControlType(IntEnum): UPDATE_ERASE_SQIF_CFM = 30 +class VmuPacketType(IntEnum): + # Replies to commands from the VMU_PACKET BT notifications + UPDATE_START_CFM = 2 + UPDATE_DATA_BYTES_REQ = 3 + UPDATE_ABORT_CFM = 8 + UPDATE_TRANSFER_COMPLETE_IND = 11 + UPDATE_COMMIT_RES = 15 + UPDATE_SYNC_CFM = 20 + UPDATE_IS_VALIDATION_DONE_CFM = 23 + UPDATE_COMMIT_ERASE_SQIF_RES = 29 + UPDATE_ERROR = 17 + UPDATE_COMPLETE_IND = 18 + + class BoolTransform: def forward(self, x: int) -> bool: return bool(x) @@ -177,13 +181,6 @@ class VmControlUpdateDataBytesReq(Bitfield): n_bytes_skip: int = bf_int(32) -# UPDATE_COMMIT_RES = 15 -# UPDATE_COMMIT_RES_2 = 29 # Probably for ERASE_SQIF_CFM? -# UPDATE_SYNC_CFM = 20 -# UPDATE_IS_VALIDATION_DONE_CFM = 23 -# VM_UPDATE_ERROR = 17 -# UPDATE_COMPLETE_IND = 18 - def vm_control_disc(m: VmControlBody): match m.vm_control_type: case VmControlType.UPDATE_SYNC_REQ: @@ -202,28 +199,35 @@ def vm_control_disc(m: VmControlBody): out = VmControlUpdateInProgressRes case VmControlType.UPDATE_ABORT_REQ: out = VmControlUpdateAbortReq - case VmControlType.UPDATE_DATA_BYTES_REQ: + case _: + return bf_bytes(m.n_bytes_payload) + + return bf_bitfield(out, m.n_bytes_payload*8) + + +def vmu_packet_desc(m: VmuPacket): + match m.vmu_packet_type: + case VmuPacketType.UPDATE_DATA_BYTES_REQ: out = VmControlUpdateDataBytesReq - case VmControlType.UPDATE_SYNC_CFM: + case VmuPacketType.UPDATE_SYNC_CFM: out = VmControlUpdateSyncCfm - case VmControlType.UPDATE_COMPLETE_IND: + case VmuPacketType.UPDATE_COMPLETE_IND: out = VmControlUpdateCompleteInd - case VmControlType.UPDATE_TRANSFER_COMPLETE_IND: + case VmuPacketType.UPDATE_TRANSFER_COMPLETE_IND: out = VmControlUpdateTransferCompleteInd - case VmControlType.UPDATE_START_CFM: + case VmuPacketType.UPDATE_START_CFM: out = VmControlUpdateStartCfm - case VmControlType.UPDATE_ERROR: + case VmuPacketType.UPDATE_ERROR: out = VmControlUpdateError - case VmControlType.UPDATE_ABORT_CFM: + case VmuPacketType.UPDATE_ABORT_CFM: out = VmControlUpdateAbortCfm - case _: return bf_bytes(m.n_bytes_payload) return bf_bitfield(out, m.n_bytes_payload*8) -VmControlCommand = t.Union[ +VmControlMessage = t.Union[ VmControlUpdateSyncReq, VmControlUpdateStartReq, VmControlUpdateDataStartReq, @@ -232,6 +236,9 @@ def vm_control_disc(m: VmControlBody): VmControlUpdateTransferCompleteRes, VmControlUpdateInProgressRes, VmControlUpdateAbortReq, +] + +VmuPacketMessage = t.Union[ VmControlUpdateDataBytesReq, VmControlUpdateSyncCfm, VmControlUpdateCompleteInd, @@ -245,7 +252,13 @@ def vm_control_disc(m: VmControlBody): class VmControlBody(Bitfield): vm_control_type: VmControlType = bf_int_enum(VmControlType, 8) n_bytes_payload: int = bf_int(16) - command: VmControlCommand | bytes = bf_dyn(vm_control_disc) + msg: VmControlMessage | bytes = bf_dyn(vm_control_disc) + + +class VmuPacket(Bitfield): + vmu_packet_type: VmuPacketType = bf_int_enum(VmuPacketType, 8) + n_bytes_payload: int = bf_int(16) + msg: VmuPacketMessage | bytes = bf_dyn(vmu_packet_desc) class VmControlReplyBody(Bitfield): From 47bd6058691c5bb4edb7b413dadc6839805dc0c0 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Thu, 13 Feb 2025 10:50:30 -0800 Subject: [PATCH 12/85] reorder / relabel message names --- src/benlink/protocol/command/vm.py | 42 ++++++++++++++++-------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index ea948bf..5f3589f 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -9,20 +9,20 @@ # # 1. VM_CONNECT # 2. VM_CONTROL: -# a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) -# b. UPDATE_START -# c. UPDATE_DATA_START -# d. UPDATE_DATA (145 bytes at a time. repeat until all data is sent, except for the last fragment) +# a. UPDATE_SYNC_REQ (UPDATE_SYNC_CFM) (with last 4 bytes of firmware md5sum) +# b. UPDATE_START_REQ (UPDATE_START_CFM) +# c. UPDATE_DATA_START_REQ +# d. (UPDATE_DATA_BYTES_REQ) UPDATE_DATA (145 bytes at a time. repeat until all data is sent, except for the last fragment) # e. UPDATE_DATA (final fragment with is_final_fragment=True) -# f. UPDATE_IS_VALIDATION_DONE -# g. UPDATE_TRANSFER_COMPLETE (triggers reboot?) +# f. UPDATE_IS_VALIDATION_DONE_REQ (UPDATE_TRANSFER_COMPLETE_IND) +# g. UPDATE_TRANSFER_COMPLETE_RES (triggers REStart?) # # Reboot happens? # # 3. VM_CONNECT -# h. UPDATE_SYNC (with last 4 bytes of firmware md5sum) -# i. UPDATE_START -# j. UPDATE_IN_PROGRESS +# h. UPDATE_SYNC_REQ (UPDATE_SYNC_CFM) (with last 4 bytes of firmware md5sum) +# i. UPDATE_START_REQ (UPDATE_START_CFM) +# j. UPDATE_IN_PROGRESS_RES (UPDATE_COMPLETE_IND) # 4. VM_DISCONNECT ##################################################################### @@ -30,11 +30,11 @@ # # 1. VM_CONNECT # 2. VM_CONTROL: -# a. UPDATE_SYNC (with last 4 bytes of firmware md5sum) -# b. UPDATE_START -# c. UPDATE_DATA_START -# d. UPDATE_DATA (repeat until all data is sent) -# e. UPDATE_ABORT +# a. UPDATE_SYNC_REQ (UPDATE_SYNC_CFM) +# b. UPDATE_START_REQ (UPDATE_START_CFM) +# c. UPDATE_DATA_START_REQ +# d. (UPDATE_DATA_BYTES_REQ) UPDATE_DATA +# e. UPDATE_ABORT_REQ (UPDATE_ABORT_CFM) # 3. VM_DISCONNECT @@ -71,12 +71,12 @@ class VmuPacketType(IntEnum): UPDATE_DATA_BYTES_REQ = 3 UPDATE_ABORT_CFM = 8 UPDATE_TRANSFER_COMPLETE_IND = 11 - UPDATE_COMMIT_RES = 15 UPDATE_SYNC_CFM = 20 - UPDATE_IS_VALIDATION_DONE_CFM = 23 - UPDATE_COMMIT_ERASE_SQIF_RES = 29 - UPDATE_ERROR = 17 UPDATE_COMPLETE_IND = 18 + UPDATE_ERROR = 17 # Not seen in logs + UPDATE_IS_VALIDATION_DONE_CFM = 23 # Not seen in logs + UPDATE_COMMIT_ERASE_SQIF_RES = 29 # Not seen in logs + UPDATE_COMMIT_RES = 15 # Not seen in logs class BoolTransform: @@ -129,6 +129,10 @@ class UpdateState(IntEnum): TRANSFER_COMPLETE = 2 IN_PROGRESS = 3 COMMIT = 4 + + +class UpdateStartCfmCode(IntEnum): + OK = 0 GOTO_NEXT_STATE = 9 @@ -153,7 +157,7 @@ class VmControlUpdateSyncCfm(Bitfield): class VmControlUpdateStartCfm(Bitfield): - update_state: UpdateState = bf_int_enum(UpdateState, 8) + cfm_code: UpdateStartCfmCode = bf_int_enum(UpdateStartCfmCode, 8) unknown: bytes = bf_bytes(2) From 63156b92c028f4036ff0fd63a0a523ecbfa87a8f Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:02:47 -0700 Subject: [PATCH 13/85] add firmware check/download/assemble with CLI --- pyproject.toml | 9 + src/benlink/firmware.py | 484 ++++++++++++++++++++++++++++++++++++++++ tests/test_firmware.py | 115 ++++++++++ 3 files changed, 608 insertions(+) create mode 100644 src/benlink/firmware.py create mode 100644 tests/test_firmware.py diff --git a/pyproject.toml b/pyproject.toml index 40118b8..c73e558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,12 @@ dependencies = [ "bleak >=0.22.0", "pydantic >=2.6.0" ] +[project.optional-dependencies] +firmware = [ + "bsdiff4 >=1.2.0", + "grpcio >=1.60.0", +] + classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", @@ -31,3 +37,6 @@ requires-python = ">=3.10" [tool.setuptools.packages.find] where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py new file mode 100644 index 0000000..af3b2d6 --- /dev/null +++ b/src/benlink/firmware.py @@ -0,0 +1,484 @@ +""" +# Overview + +Firmware update support for Benshi radios. + +This module is deliberately excluded from `benlink`'s default namespace. Flashing +firmware can brick a radio, so it must be imported explicitly: + +```python +import benlink.firmware +``` + +Firmware is distributed as a shared **base image** plus a per-release **patch** in +BSDIFF40 format. Assembling the two yields the image the radio expects. Neither is +redistributed by benlink — both are fetched from the vendor's servers at the user's +request. + +Two ways to find an image: + +1. Ask the vendor's update server what the latest release is for a given product id + (`check_update`). Requires `grpcio`, and returns md5s that let the assembled image + be verified. +2. Address the object store directly by version number (`oss_update_info`). Needs no + RPC and no product id, which keeps this module working if the update server + changes. + +# CLI + +```bash +python -m benlink.firmware check --product-id 259 +python -m benlink.firmware fetch --product-id 259 -o fw.bin +python -m benlink.firmware fetch --version 147 -o fw.bin +python -m benlink.firmware assemble --base upgrade_base.bin --patch patch.bin -o fw.bin +``` + +`assemble` is fully offline. `check` and `fetch --product-id` contact the update +server; `fetch --version` contacts only the object store. + +# Notes + +The product id is read from the radio via `GET_DEV_INFO` (`DeviceInfo.product_id`). +It is not unique across vendors — the VR-N76 and GA-5WB both report 259. + +An earlier RPC (`/benshikj.APP/CheckUpdate`, keyed on a model string rather than a +product id) was reported in issue #10 to return empty responses, while the method +used here was reported working. Whether the old one was retired or was simply being +called wrongly is unconfirmed — neither has been tested from this codebase. +""" + +from __future__ import annotations +import typing as t +import argparse +import asyncio +import hashlib +import io +import sys +import urllib.request +import zipfile + +OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" +"""@private""" + +RPC_HOST = "rpc.benshikj.com:800" +"""@private""" + +RPC_METHOD = "/benshikj.DeviceManagement/CheckFirmwareUpdate" +"""@private""" + +RPC_TIMEOUT = 10.0 +"""@private""" + +DEFAULT_PATCH_NAME = "patch_base_to_vr_n76" +"""Patch filename for the VR-N76 / GA-5WB. The UV-Pro uses `patch_base_to_vr_n76_m`.""" + +DEFAULT_BASE_VERSION = 1 +"""Base image version. Independent of the firmware version; shared across releases.""" + + +def _require(module: str, package: str): + try: + return __import__(module) + except ImportError: + raise ImportError( + f"{package} is required for this operation. " + f"Install with: pip install benlink[firmware]" + ) + + +##################### +# proto3 wire format +# +# The update server speaks gRPC, but only two message shapes are needed, so they are +# encoded by hand rather than taking a protoc dependency: +# +# CheckFirmwareUpdateRequest { productId=1, firmwareVersion=2, beta=3, +# userId=4, inviteCode=5 } +# CheckFirmwareUpdateResult { firmware:FirmwareInfo=1, base:FirmwareInfo=2 } +# FirmwareInfo { version=1, url=2, md5=3, +# releaseNotes=4, releaseDate=5 } +# +# proto3 omits zero-valued fields, so sending productId alone requests the latest +# release. + +def _encode_varint(value: int) -> bytes: + out = bytearray() + while value > 0x7F: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value) + return bytes(out) + + +def _encode_varint_field(field: int, value: int) -> bytes: + return _encode_varint(field << 3) + _encode_varint(value) + + +def _decode_fields(data: bytes) -> t.Iterator[t.Tuple[int, int, bytes]]: + pos = 0 + + def read_varint() -> int: + nonlocal pos + value = shift = 0 + while pos < len(data): + byte = data[pos] + pos += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + break + shift += 7 + return value + + while pos < len(data): + tag = read_varint() + field, wire_type = tag >> 3, tag & 0x7 + match wire_type: + case 0: + yield field, wire_type, _encode_varint(read_varint()) + case 1: + yield field, wire_type, data[pos:pos + 8] + pos += 8 + case 2: + length = read_varint() + yield field, wire_type, data[pos:pos + length] + pos += length + case 5: + yield field, wire_type, data[pos:pos + 4] + pos += 4 + case _: + return + + +def _decode_varint(data: bytes) -> int: + value = shift = 0 + for byte in data: + value |= (byte & 0x7F) << shift + if not byte & 0x80: + break + shift += 7 + return value + + +##################### +# Data + +class FirmwareInfo(t.NamedTuple): + """One downloadable artifact (either the patch or the base image).""" + version: int + url: str + md5: str + + +class UpdateInfo(t.NamedTuple): + """The patch and base image that together make up a firmware release.""" + firmware: FirmwareInfo + base: FirmwareInfo + + +class FirmwareBundle(t.NamedTuple): + """An assembled, ready-to-flash firmware image.""" + data: bytes + update_info: UpdateInfo + + @property + def md5(self) -> str: + return hashlib.md5(self.data).hexdigest() + + @property + def md5_tail(self) -> bytes: + """Last 4 bytes of the md5 digest, as sent in `UPDATE_SYNC_REQ`.""" + return bytes.fromhex(self.md5)[-4:] + + @property + def size(self) -> int: + return len(self.data) + + def save(self, path: str) -> None: + with open(path, "wb") as f: + f.write(self.data) + + +ProgressCallback = t.Callable[[str, int, int], None] +"""`progress(label, bytes_done, bytes_total)`. `bytes_total` is 0 if unknown.""" + + +##################### +# Finding an update + +def _parse_firmware_info(data: bytes) -> FirmwareInfo: + version, url, md5 = 0, "", "" + for field, _, value in _decode_fields(data): + match field: + case 1: + version = _decode_varint(value) + case 2: + url = value.decode("utf-8") + case 3: + md5 = value.decode("utf-8") + return FirmwareInfo(version=version, url=url, md5=md5) + + +def _parse_check_result(data: bytes) -> UpdateInfo | None: + firmware = base = None + for field, _, value in _decode_fields(data): + match field: + case 1: + firmware = _parse_firmware_info(value) + case 2: + base = _parse_firmware_info(value) + if firmware is None or base is None or not firmware.url or not base.url: + return None + return UpdateInfo(firmware=firmware, base=base) + + +async def check_update( + product_id: int, + firmware_version: int = 0, +) -> UpdateInfo | None: + """Ask the update server for the latest release for `product_id`. + + `firmware_version` is the currently installed internal version; leaving it at 0 + always returns the latest. Returns `None` if the server reports no update. + + Requires `grpcio`. + """ + grpc = _require("grpc", "grpcio") + + request = _encode_varint_field(1, product_id) + if firmware_version: + request += _encode_varint_field(2, firmware_version) + + credentials = grpc.ssl_channel_credentials() + async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: + call = channel.unary_unary( + RPC_METHOD, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + ) + try: + response: bytes = await call(request, timeout=RPC_TIMEOUT) + except grpc.aio.AioRpcError as e: + raise RuntimeError(f"update check failed: {e.code()} {e.details()}") + + if not response: + return None + + return _parse_check_result(response) + + +def oss_update_info( + version: int, + patch_name: str = DEFAULT_PATCH_NAME, + base_version: int = DEFAULT_BASE_VERSION, +) -> UpdateInfo: + """Construct object-store URLs for a known version, without contacting the + update server. + + No md5s are available this way, so an image assembled from these URLs cannot be + verified against the vendor's own checksums. + """ + return UpdateInfo( + firmware=FirmwareInfo( + version=version, + url=f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin", + md5="", + ), + base=FirmwareInfo( + version=base_version, + url=f"{OSS_BASE_URL}/upgrade_base_v{base_version}.bin.zip", + md5="", + ), + ) + + +##################### +# Downloading and assembling + +def _download(url: str, label: str, progress: ProgressCallback | None) -> bytes: + with urllib.request.urlopen(url) as response: + total = int(response.headers.get("Content-Length", 0)) + chunks: t.List[bytes] = [] + received = 0 + while chunk := response.read(65536): + chunks.append(chunk) + received += len(chunk) + if progress: + progress(label, received, total) + return b"".join(chunks) + + +def _verify(data: bytes, expected_md5: str, label: str) -> None: + if not expected_md5: + return + actual = hashlib.md5(data).hexdigest() + if actual != expected_md5: + raise RuntimeError( + f"{label} md5 mismatch: expected {expected_md5}, got {actual}" + ) + + +def assemble(base: bytes, patch: bytes) -> bytes: + """Apply a BSDIFF40 patch to a base image. + + `base` may be either the raw base image or the zip it ships in. + """ + bsdiff4 = _require("bsdiff4", "bsdiff4") + + if base[:2] == b"PK": + with zipfile.ZipFile(io.BytesIO(base)) as zf: + names = [n for n in zf.namelist() if n.endswith(".bin")] + if not names: + raise RuntimeError("no .bin found in base zip") + base = zf.read(names[0]) + + if patch[:8] != b"BSDIFF40": + raise RuntimeError( + f"unexpected patch magic {patch[:8]!r}, expected b'BSDIFF40'" + ) + + return bsdiff4.patch(base, patch) + + +async def download_firmware( + update_info: UpdateInfo, + progress: ProgressCallback | None = None, +) -> FirmwareBundle: + """Download the patch and base image and assemble them. + + Downloaded artifacts are checked against the server's md5s when available. + + Requires `bsdiff4`. + """ + patch, base = await asyncio.gather( + asyncio.to_thread(_download, update_info.firmware.url, "patch", progress), + asyncio.to_thread(_download, update_info.base.url, "base", progress), + ) + + _verify(patch, update_info.firmware.md5, "patch") + _verify(base, update_info.base.md5, "base") + + return FirmwareBundle( + data=await asyncio.to_thread(assemble, base, patch), + update_info=update_info, + ) + + +async def fetch_firmware( + product_id: int, + firmware_version: int = 0, + progress: ProgressCallback | None = None, +) -> FirmwareBundle | None: + """Check for an update and download it if one is available.""" + update_info = await check_update(product_id, firmware_version) + if update_info is None: + return None + return await download_firmware(update_info, progress) + + +##################### +# CLI + +def _print_progress(label: str, done: int, total: int) -> None: + pct = f"{100 * done // total}%" if total else f"{done} bytes" + print(f"\r{label}: {pct}", end="", file=sys.stderr, flush=True) + + +def _print_update_info(info: UpdateInfo) -> None: + print(f"firmware v{info.firmware.version}") + print(f" url {info.firmware.url}") + print(f" md5 {info.firmware.md5}") + print(f"base v{info.base.version}") + print(f" url {info.base.url}") + print(f" md5 {info.base.md5}") + + +async def _cmd_check(args: argparse.Namespace) -> int: + info = await check_update(args.product_id, args.firmware_version) + if info is None: + print("no update available") + return 1 + _print_update_info(info) + return 0 + + +async def _cmd_fetch(args: argparse.Namespace) -> int: + if args.product_id is not None: + info = await check_update(args.product_id, args.firmware_version) + if info is None: + print("no update available") + return 1 + else: + info = oss_update_info(args.version, args.patch_name, args.base_version) + + _print_update_info(info) + bundle = await download_firmware(info, _print_progress) + print(file=sys.stderr) + + bundle.save(args.output) + print(f"wrote {args.output} ({bundle.size} bytes, md5 {bundle.md5})") + return 0 + + +async def _cmd_assemble(args: argparse.Namespace) -> int: + with open(args.base, "rb") as f: + base = f.read() + with open(args.patch, "rb") as f: + patch = f.read() + + data = assemble(base, patch) + with open(args.output, "wb") as f: + f.write(data) + + print(f"wrote {args.output} ({len(data)} bytes, " + f"md5 {hashlib.md5(data).hexdigest()})") + return 0 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m benlink.firmware", + description="Download and assemble Benshi radio firmware.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + check = subparsers.add_parser( + "check", help="ask the update server for the latest release") + check.add_argument("--product-id", type=int, required=True, + help="from GET_DEV_INFO, e.g. 259 for VR-N76 / GA-5WB") + check.add_argument("--firmware-version", type=int, default=0, + help="currently installed internal version (default: 0)") + check.set_defaults(run=_cmd_check) + + fetch = subparsers.add_parser( + "fetch", help="download and assemble a firmware image") + source = fetch.add_mutually_exclusive_group(required=True) + source.add_argument("--product-id", type=int, + help="ask the update server for the latest release") + source.add_argument("--version", type=int, + help="fetch a known version directly, without the server") + fetch.add_argument("--firmware-version", type=int, default=0) + fetch.add_argument("--patch-name", default=DEFAULT_PATCH_NAME, + help=f"default: {DEFAULT_PATCH_NAME}") + fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, + help=f"default: {DEFAULT_BASE_VERSION}") + fetch.add_argument("-o", "--output", required=True) + fetch.set_defaults(run=_cmd_fetch) + + assemble_cmd = subparsers.add_parser( + "assemble", help="assemble from local files (offline)") + assemble_cmd.add_argument("--base", required=True, + help="base image, raw or zipped") + assemble_cmd.add_argument("--patch", required=True) + assemble_cmd.add_argument("-o", "--output", required=True) + assemble_cmd.set_defaults(run=_cmd_assemble) + + return parser + + +if __name__ == "__main__": + args = _parser().parse_args() + try: + sys.exit(asyncio.run(args.run(args))) + except (RuntimeError, ImportError, OSError) as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/test_firmware.py b/tests/test_firmware.py new file mode 100644 index 0000000..33644b9 --- /dev/null +++ b/tests/test_firmware.py @@ -0,0 +1,115 @@ +import io +import zipfile + +import pytest + +from benlink.firmware import ( + FirmwareBundle, + FirmwareInfo, + UpdateInfo, + _decode_fields, + _encode_varint, + _encode_varint_field, + _parse_check_result, + assemble, + oss_update_info, +) + +bsdiff4 = pytest.importorskip("bsdiff4") + + +def test_encode_varint(): + assert _encode_varint(0) == b"\x00" + assert _encode_varint(1) == b"\x01" + assert _encode_varint(127) == b"\x7f" + assert _encode_varint(128) == b"\x80\x01" + assert _encode_varint(259) == b"\x83\x02" + + +def test_encode_varint_field(): + # field 1, wire type 0, value 259 + assert _encode_varint_field(1, 259) == b"\x08\x83\x02" + + +def test_decode_fields_roundtrip(): + data = _encode_varint_field(1, 259) + _encode_varint_field(2, 147) + assert [(f, w) for f, w, _ in _decode_fields(data)] == [(1, 0), (2, 0)] + + +def _string_field(field: int, value: str) -> bytes: + raw = value.encode() + return _encode_varint(field << 3 | 2) + _encode_varint(len(raw)) + raw + + +def _message_field(field: int, value: bytes) -> bytes: + return _encode_varint(field << 3 | 2) + _encode_varint(len(value)) + value + + +def test_parse_check_result(): + firmware = ( + _encode_varint_field(1, 147) + + _string_field(2, "https://example.invalid/patch.bin") + + _string_field(3, "0c0d095da50bebe664822adcb244834a") + ) + base = ( + _encode_varint_field(1, 1) + + _string_field(2, "https://example.invalid/base.zip") + + _string_field(3, "74b6d097d8d2d9d2d9fac88133198a08") + ) + + result = _parse_check_result( + _message_field(1, firmware) + _message_field(2, base) + ) + + assert result == UpdateInfo( + firmware=FirmwareInfo( + version=147, + url="https://example.invalid/patch.bin", + md5="0c0d095da50bebe664822adcb244834a", + ), + base=FirmwareInfo( + version=1, + url="https://example.invalid/base.zip", + md5="74b6d097d8d2d9d2d9fac88133198a08", + ), + ) + + +def test_parse_check_result_empty_means_no_update(): + assert _parse_check_result(b"") is None + + +def test_oss_update_info(): + info = oss_update_info(147) + assert info.firmware.url.endswith("/firmware/v147/patch_base_to_vr_n76.bin") + assert info.base.url.endswith("/upgrade_base_v1.bin.zip") + assert info.firmware.md5 == "" + + +def test_assemble_raw_base(): + base = b"the quick brown fox" * 100 + expected = b"the slow brown fox" * 100 + assert assemble(base, bsdiff4.diff(base, expected)) == expected + + +def test_assemble_zipped_base(): + base = b"the quick brown fox" * 100 + expected = b"the slow brown fox" * 100 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("upgrade_base.bin", base) + + assert assemble(buf.getvalue(), bsdiff4.diff(base, expected)) == expected + + +def test_assemble_rejects_bad_patch_magic(): + with pytest.raises(RuntimeError, match="unexpected patch magic"): + assemble(b"base", b"NOTAPATCH" + b"\x00" * 32) + + +def test_bundle_md5_tail(): + bundle = FirmwareBundle(data=b"hello", update_info=oss_update_info(1)) + assert bundle.md5 == "5d41402abc4b2a76b9719d911017c592" + assert bundle.md5_tail == bytes.fromhex("1017c592") + assert bundle.size == 5 From 7e58a760eb7f34eb4096650afcac2822622062de Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:03:35 -0700 Subject: [PATCH 14/85] drop note about earlier firmware rpc --- src/benlink/firmware.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py index af3b2d6..4cff4a1 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware.py @@ -40,11 +40,6 @@ The product id is read from the radio via `GET_DEV_INFO` (`DeviceInfo.product_id`). It is not unique across vendors — the VR-N76 and GA-5WB both report 259. - -An earlier RPC (`/benshikj.APP/CheckUpdate`, keyed on a model string rather than a -product id) was reported in issue #10 to return empty responses, while the method -used here was reported working. Whether the old one was retired or was simply being -called wrongly is unconfirmed — neither has been tested from this codebase. """ from __future__ import annotations From 2dcca75f066ff34b01a397b6da09b45161ecaa61 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:09:21 -0700 Subject: [PATCH 15/85] add --product flag with known radio product ids and patch names --- src/benlink/firmware.py | 87 +++++++++++++++++++++++++++++++++-------- tests/test_firmware.py | 30 ++++++++++++++ 2 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py index 4cff4a1..359ac27 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware.py @@ -64,8 +64,22 @@ RPC_TIMEOUT = 10.0 """@private""" -DEFAULT_PATCH_NAME = "patch_base_to_vr_n76" -"""Patch filename for the VR-N76 / GA-5WB. The UV-Pro uses `patch_base_to_vr_n76_m`.""" +PRODUCTS: t.Dict[str, t.Tuple[int, str]] = { + "VR_N76": (259, "patch_base_to_vr_n76"), + "GA_5WB": (259, "patch_base_to_vr_n76"), + "UV_PRO": (260, "patch_base_to_vr_n76_m"), + "VR_N75": (261, "patch_base_to_vr_n75_h2"), +} +"""Known radios, as `name: (product_id, patch_name)`. + +Every patch name here was returned by the update server for the corresponding product +id. Note that 259 covers both the VR-N76 and the GA-5WB — they share a patch series, +confirmed by a GA-5WB flash capture whose `md5sum_tail` matches +`patch_base_to_vr_n76.v120` assembled against the shared base. +""" + +DEFAULT_PATCH_NAME = PRODUCTS["VR_N76"][1] +"""@private""" DEFAULT_BASE_VERSION = 1 """Base image version. Independent of the firmware version; shared across releases.""" @@ -378,17 +392,48 @@ def _print_progress(label: str, done: int, total: int) -> None: print(f"\r{label}: {pct}", end="", file=sys.stderr, flush=True) +def _print_firmware_info(label: str, info: FirmwareInfo) -> None: + # The server populates version for the patch but not for the base image. + print(f"{label} v{info.version}" if info.version else label) + print(f" url {info.url}") + if info.md5: + print(f" md5 {info.md5}") + + def _print_update_info(info: UpdateInfo) -> None: - print(f"firmware v{info.firmware.version}") - print(f" url {info.firmware.url}") - print(f" md5 {info.firmware.md5}") - print(f"base v{info.base.version}") - print(f" url {info.base.url}") - print(f" md5 {info.base.md5}") + _print_firmware_info("firmware", info.firmware) + _print_firmware_info("base", info.base) + + +def _resolve_product(args: argparse.Namespace) -> t.Tuple[int | None, str]: + """Resolve `--product` into a product id and patch name, letting the explicit + `--product-id` / `--patch-name` flags override either half.""" + product_id, patch_name = None, DEFAULT_PATCH_NAME + + if getattr(args, "product", None): + product_id, patch_name = PRODUCTS[args.product] + + if getattr(args, "product_id", None) is not None: + product_id = args.product_id + if getattr(args, "patch_name", None) is not None: + patch_name = args.patch_name + + return product_id, patch_name + + +def _require_product_id(product_id: int | None) -> int: + if product_id is None: + raise RuntimeError( + "a product is required: pass --product " + f"({', '.join(PRODUCTS)}) or --product-id" + ) + return product_id async def _cmd_check(args: argparse.Namespace) -> int: - info = await check_update(args.product_id, args.firmware_version) + product_id, _ = _resolve_product(args) + info = await check_update(_require_product_id(product_id), + args.firmware_version) if info is None: print("no update available") return 1 @@ -397,13 +442,16 @@ async def _cmd_check(args: argparse.Namespace) -> int: async def _cmd_fetch(args: argparse.Namespace) -> int: - if args.product_id is not None: - info = await check_update(args.product_id, args.firmware_version) + product_id, patch_name = _resolve_product(args) + + if args.version is None: + info = await check_update(_require_product_id(product_id), + args.firmware_version) if info is None: print("no update available") return 1 else: - info = oss_update_info(args.version, args.patch_name, args.base_version) + info = oss_update_info(args.version, patch_name, args.base_version) _print_update_info(info) bundle = await download_firmware(info, _print_progress) @@ -438,8 +486,10 @@ def _parser() -> argparse.ArgumentParser: check = subparsers.add_parser( "check", help="ask the update server for the latest release") - check.add_argument("--product-id", type=int, required=True, - help="from GET_DEV_INFO, e.g. 259 for VR-N76 / GA-5WB") + check_product = check.add_mutually_exclusive_group(required=True) + check_product.add_argument("--product", choices=sorted(PRODUCTS)) + check_product.add_argument("--product-id", type=int, + help="from GET_DEV_INFO, for radios not listed above") check.add_argument("--firmware-version", type=int, default=0, help="currently installed internal version (default: 0)") check.set_defaults(run=_cmd_check) @@ -447,13 +497,16 @@ def _parser() -> argparse.ArgumentParser: fetch = subparsers.add_parser( "fetch", help="download and assemble a firmware image") source = fetch.add_mutually_exclusive_group(required=True) + source.add_argument("--product", choices=sorted(PRODUCTS), + help="ask the update server for this radio's latest release") source.add_argument("--product-id", type=int, - help="ask the update server for the latest release") + help="as --product, for radios not listed above") source.add_argument("--version", type=int, help="fetch a known version directly, without the server") fetch.add_argument("--firmware-version", type=int, default=0) - fetch.add_argument("--patch-name", default=DEFAULT_PATCH_NAME, - help=f"default: {DEFAULT_PATCH_NAME}") + fetch.add_argument("--patch-name", + help=f"only used with --version (default: " + f"{DEFAULT_PATCH_NAME})") fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, help=f"default: {DEFAULT_BASE_VERSION}") fetch.add_argument("-o", "--output", required=True) diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 33644b9..c3bf1a8 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -4,6 +4,8 @@ import pytest from benlink.firmware import ( + DEFAULT_PATCH_NAME, + PRODUCTS, FirmwareBundle, FirmwareInfo, UpdateInfo, @@ -108,6 +110,34 @@ def test_assemble_rejects_bad_patch_magic(): assemble(b"base", b"NOTAPATCH" + b"\x00" * 32) +def test_resolve_product(): + from argparse import Namespace + + from benlink.firmware import PRODUCTS, _resolve_product + + assert _resolve_product( + Namespace(product="UV_PRO", product_id=None, patch_name=None) + ) == PRODUCTS["UV_PRO"] + + # explicit flags override either half of --product + assert _resolve_product( + Namespace(product="UV_PRO", product_id=999, patch_name=None) + ) == (999, PRODUCTS["UV_PRO"][1]) + + assert _resolve_product( + Namespace(product="UV_PRO", product_id=None, patch_name="custom") + ) == (PRODUCTS["UV_PRO"][0], "custom") + + assert _resolve_product( + Namespace(product=None, product_id=None, patch_name=None) + ) == (None, DEFAULT_PATCH_NAME) + + +def test_ga5wb_shares_vr_n76_patch_series(): + # Confirmed against a GA-5WB flash capture; see PRODUCTS docstring. + assert PRODUCTS["GA_5WB"] == PRODUCTS["VR_N76"] + + def test_bundle_md5_tail(): bundle = FirmwareBundle(data=b"hello", update_info=oss_update_info(1)) assert bundle.md5 == "5d41402abc4b2a76b9719d911017c592" From c196d0bdd4f6aa46d2ee1d1fe4c899ab3c9e1400 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:12:15 -0700 Subject: [PATCH 16/85] allow reusing a local base image when fetching firmware --- src/benlink/firmware.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py index 359ac27..2e9842c 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware.py @@ -351,20 +351,29 @@ def assemble(base: bytes, patch: bytes) -> bytes: async def download_firmware( update_info: UpdateInfo, progress: ProgressCallback | None = None, + base: bytes | None = None, ) -> FirmwareBundle: """Download the patch and base image and assemble them. + The base image is shared across radios and releases, so pass `base` to reuse a + local copy instead of downloading it again. + Downloaded artifacts are checked against the server's md5s when available. Requires `bsdiff4`. """ - patch, base = await asyncio.gather( - asyncio.to_thread(_download, update_info.firmware.url, "patch", progress), - asyncio.to_thread(_download, update_info.base.url, "base", progress), - ) + if base is None: + patch, base = await asyncio.gather( + asyncio.to_thread( + _download, update_info.firmware.url, "patch", progress), + asyncio.to_thread(_download, update_info.base.url, "base", progress), + ) + _verify(base, update_info.base.md5, "base") + else: + patch = await asyncio.to_thread( + _download, update_info.firmware.url, "patch", progress) _verify(patch, update_info.firmware.md5, "patch") - _verify(base, update_info.base.md5, "base") return FirmwareBundle( data=await asyncio.to_thread(assemble, base, patch), @@ -453,8 +462,15 @@ async def _cmd_fetch(args: argparse.Namespace) -> int: else: info = oss_update_info(args.version, patch_name, args.base_version) + base = None + if args.base: + with open(args.base, "rb") as f: + base = f.read() + _print_update_info(info) - bundle = await download_firmware(info, _print_progress) + sys.stdout.flush() + + bundle = await download_firmware(info, _print_progress, base) print(file=sys.stderr) bundle.save(args.output) @@ -509,6 +525,8 @@ def _parser() -> argparse.ArgumentParser: f"{DEFAULT_PATCH_NAME})") fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, help=f"default: {DEFAULT_BASE_VERSION}") + fetch.add_argument("--base", + help="local base image to reuse instead of downloading it") fetch.add_argument("-o", "--output", required=True) fetch.set_defaults(run=_cmd_fetch) From ffb360855e05571d6bb1f19c834a313c8191c0cf Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:15:57 -0700 Subject: [PATCH 17/85] use generated protobuf stubs for the firmware update check --- Makefile | 7 +- pyproject.toml | 17 ++-- src/benlink/_benshikj.proto | 36 ++++++++ src/benlink/_benshikj_pb2.py | 42 +++++++++ src/benlink/_benshikj_pb2.pyi | 42 +++++++++ src/benlink/_benshikj_pb2_grpc.py | 97 ++++++++++++++++++++ src/benlink/firmware.py | 141 ++++++------------------------ tests/test_firmware.py | 68 +++++--------- 8 files changed, 282 insertions(+), 168 deletions(-) create mode 100644 src/benlink/_benshikj.proto create mode 100644 src/benlink/_benshikj_pb2.py create mode 100644 src/benlink/_benshikj_pb2.pyi create mode 100644 src/benlink/_benshikj_pb2_grpc.py diff --git a/Makefile b/Makefile index 3af9619..ab4ed9e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ -.PHONY: docs +.PHONY: docs proto all: docs +proto: + cd src && python -m grpc_tools.protoc -I. \ + --python_out=. --pyi_out=. --grpc_python_out=. \ + benlink/_benshikj.proto + docs: pdoc ./src/benlink -o docs --logo /logo.svg cp ./assets/logo-transparent.svg docs/logo.svg diff --git a/pyproject.toml b/pyproject.toml index c73e558..2c8cc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,12 +14,6 @@ dependencies = [ "bleak >=0.22.0", "pydantic >=2.6.0" ] -[project.optional-dependencies] -firmware = [ - "bsdiff4 >=1.2.0", - "grpcio >=1.60.0", -] - classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", @@ -31,6 +25,17 @@ classifiers = [ ] requires-python = ">=3.10" +[project.optional-dependencies] +firmware = [ + "bsdiff4 >=1.2.0", + "grpcio >=1.60.0", + "protobuf >=4.21.0", +] +# Only needed to regenerate the protobuf stubs; see `make proto`. +proto = [ + "grpcio-tools >=1.60.0", +] + [project.urls] "Homepage" = "https://github.com/khusmann/benlink" "Bug Tracker" = "https://github.com/khusmann/benlink/issues" diff --git a/src/benlink/_benshikj.proto b/src/benlink/_benshikj.proto new file mode 100644 index 0000000..734b0e9 --- /dev/null +++ b/src/benlink/_benshikj.proto @@ -0,0 +1,36 @@ +// Vendor update server API, reconstructed from observed traffic. +// +// Field numbers are the contract; names here are our own. Only the firmware +// update check is modelled — the real service has other methods. +// +// Regenerate with `make proto`. + +syntax = "proto3"; + +package benshikj; + +message CheckFirmwareUpdateRequest { + int32 product_id = 1; + int32 firmware_version = 2; + bool beta = 3; + int64 user_id = 4; + int32 invite_code = 5; +} + +message FirmwareInfo { + int32 version = 1; + string url = 2; + string md5 = 3; + string release_notes = 4; + string release_date = 5; +} + +message CheckFirmwareUpdateResult { + FirmwareInfo firmware = 1; + FirmwareInfo base = 2; +} + +service DeviceManagement { + rpc CheckFirmwareUpdate(CheckFirmwareUpdateRequest) + returns (CheckFirmwareUpdateResult); +} diff --git a/src/benlink/_benshikj_pb2.py b/src/benlink/_benshikj_pb2.py new file mode 100644 index 0000000..7bc5445 --- /dev/null +++ b/src/benlink/_benshikj_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: benlink/_benshikj.proto +# Protobuf Python Version: 7.35.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 7, + 35, + 0, + '', + 'benlink/_benshikj.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x62\x65nlink/_benshikj.proto\x12\x08\x62\x65nshikj\"~\n\x1a\x43heckFirmwareUpdateRequest\x12\x12\n\nproduct_id\x18\x01 \x01(\x05\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\x05\x12\x0c\n\x04\x62\x65ta\x18\x03 \x01(\x08\x12\x0f\n\x07user_id\x18\x04 \x01(\x03\x12\x13\n\x0binvite_code\x18\x05 \x01(\x05\"f\n\x0c\x46irmwareInfo\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03md5\x18\x03 \x01(\t\x12\x15\n\rrelease_notes\x18\x04 \x01(\t\x12\x14\n\x0crelease_date\x18\x05 \x01(\t\"k\n\x19\x43heckFirmwareUpdateResult\x12(\n\x08\x66irmware\x18\x01 \x01(\x0b\x32\x16.benshikj.FirmwareInfo\x12$\n\x04\x62\x61se\x18\x02 \x01(\x0b\x32\x16.benshikj.FirmwareInfo2t\n\x10\x44\x65viceManagement\x12`\n\x13\x43heckFirmwareUpdate\x12$.benshikj.CheckFirmwareUpdateRequest\x1a#.benshikj.CheckFirmwareUpdateResultb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'benlink._benshikj_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_start=37 + _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_end=163 + _globals['_FIRMWAREINFO']._serialized_start=165 + _globals['_FIRMWAREINFO']._serialized_end=267 + _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_start=269 + _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_end=376 + _globals['_DEVICEMANAGEMENT']._serialized_start=378 + _globals['_DEVICEMANAGEMENT']._serialized_end=494 +# @@protoc_insertion_point(module_scope) diff --git a/src/benlink/_benshikj_pb2.pyi b/src/benlink/_benshikj_pb2.pyi new file mode 100644 index 0000000..22ccaf2 --- /dev/null +++ b/src/benlink/_benshikj_pb2.pyi @@ -0,0 +1,42 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class CheckFirmwareUpdateRequest(_message.Message): + __slots__ = ("product_id", "firmware_version", "beta", "user_id", "invite_code") + PRODUCT_ID_FIELD_NUMBER: _ClassVar[int] + FIRMWARE_VERSION_FIELD_NUMBER: _ClassVar[int] + BETA_FIELD_NUMBER: _ClassVar[int] + USER_ID_FIELD_NUMBER: _ClassVar[int] + INVITE_CODE_FIELD_NUMBER: _ClassVar[int] + product_id: int + firmware_version: int + beta: bool + user_id: int + invite_code: int + def __init__(self, product_id: _Optional[int] = ..., firmware_version: _Optional[int] = ..., beta: _Optional[bool] = ..., user_id: _Optional[int] = ..., invite_code: _Optional[int] = ...) -> None: ... + +class FirmwareInfo(_message.Message): + __slots__ = ("version", "url", "md5", "release_notes", "release_date") + VERSION_FIELD_NUMBER: _ClassVar[int] + URL_FIELD_NUMBER: _ClassVar[int] + MD5_FIELD_NUMBER: _ClassVar[int] + RELEASE_NOTES_FIELD_NUMBER: _ClassVar[int] + RELEASE_DATE_FIELD_NUMBER: _ClassVar[int] + version: int + url: str + md5: str + release_notes: str + release_date: str + def __init__(self, version: _Optional[int] = ..., url: _Optional[str] = ..., md5: _Optional[str] = ..., release_notes: _Optional[str] = ..., release_date: _Optional[str] = ...) -> None: ... + +class CheckFirmwareUpdateResult(_message.Message): + __slots__ = ("firmware", "base") + FIRMWARE_FIELD_NUMBER: _ClassVar[int] + BASE_FIELD_NUMBER: _ClassVar[int] + firmware: FirmwareInfo + base: FirmwareInfo + def __init__(self, firmware: _Optional[_Union[FirmwareInfo, _Mapping]] = ..., base: _Optional[_Union[FirmwareInfo, _Mapping]] = ...) -> None: ... diff --git a/src/benlink/_benshikj_pb2_grpc.py b/src/benlink/_benshikj_pb2_grpc.py new file mode 100644 index 0000000..47ac478 --- /dev/null +++ b/src/benlink/_benshikj_pb2_grpc.py @@ -0,0 +1,97 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from benlink import _benshikj_pb2 as benlink_dot___benshikj__pb2 + +GRPC_GENERATED_VERSION = '1.82.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in benlink/_benshikj_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class DeviceManagementStub: + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.CheckFirmwareUpdate = channel.unary_unary( + '/benshikj.DeviceManagement/CheckFirmwareUpdate', + request_serializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, + response_deserializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, + _registered_method=True) + + +class DeviceManagementServicer: + """Missing associated documentation comment in .proto file.""" + + def CheckFirmwareUpdate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_DeviceManagementServicer_to_server(servicer, server): + rpc_method_handlers = { + 'CheckFirmwareUpdate': grpc.unary_unary_rpc_method_handler( + servicer.CheckFirmwareUpdate, + request_deserializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.FromString, + response_serializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'benshikj.DeviceManagement', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('benshikj.DeviceManagement', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class DeviceManagement: + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def CheckFirmwareUpdate(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/benshikj.DeviceManagement/CheckFirmwareUpdate', + benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, + benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py index 2e9842c..b09652e 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware.py @@ -58,9 +58,6 @@ RPC_HOST = "rpc.benshikj.com:800" """@private""" -RPC_METHOD = "/benshikj.DeviceManagement/CheckFirmwareUpdate" -"""@private""" - RPC_TIMEOUT = 10.0 """@private""" @@ -95,79 +92,6 @@ def _require(module: str, package: str): ) -##################### -# proto3 wire format -# -# The update server speaks gRPC, but only two message shapes are needed, so they are -# encoded by hand rather than taking a protoc dependency: -# -# CheckFirmwareUpdateRequest { productId=1, firmwareVersion=2, beta=3, -# userId=4, inviteCode=5 } -# CheckFirmwareUpdateResult { firmware:FirmwareInfo=1, base:FirmwareInfo=2 } -# FirmwareInfo { version=1, url=2, md5=3, -# releaseNotes=4, releaseDate=5 } -# -# proto3 omits zero-valued fields, so sending productId alone requests the latest -# release. - -def _encode_varint(value: int) -> bytes: - out = bytearray() - while value > 0x7F: - out.append((value & 0x7F) | 0x80) - value >>= 7 - out.append(value) - return bytes(out) - - -def _encode_varint_field(field: int, value: int) -> bytes: - return _encode_varint(field << 3) + _encode_varint(value) - - -def _decode_fields(data: bytes) -> t.Iterator[t.Tuple[int, int, bytes]]: - pos = 0 - - def read_varint() -> int: - nonlocal pos - value = shift = 0 - while pos < len(data): - byte = data[pos] - pos += 1 - value |= (byte & 0x7F) << shift - if not byte & 0x80: - break - shift += 7 - return value - - while pos < len(data): - tag = read_varint() - field, wire_type = tag >> 3, tag & 0x7 - match wire_type: - case 0: - yield field, wire_type, _encode_varint(read_varint()) - case 1: - yield field, wire_type, data[pos:pos + 8] - pos += 8 - case 2: - length = read_varint() - yield field, wire_type, data[pos:pos + length] - pos += length - case 5: - yield field, wire_type, data[pos:pos + 4] - pos += 4 - case _: - return - - -def _decode_varint(data: bytes) -> int: - value = shift = 0 - for byte in data: - value |= (byte & 0x7F) << shift - if not byte & 0x80: - break - shift += 7 - return value - - ##################### # Data @@ -214,30 +138,21 @@ def save(self, path: str) -> None: ##################### # Finding an update -def _parse_firmware_info(data: bytes) -> FirmwareInfo: - version, url, md5 = 0, "", "" - for field, _, value in _decode_fields(data): - match field: - case 1: - version = _decode_varint(value) - case 2: - url = value.decode("utf-8") - case 3: - md5 = value.decode("utf-8") - return FirmwareInfo(version=version, url=url, md5=md5) - - -def _parse_check_result(data: bytes) -> UpdateInfo | None: - firmware = base = None - for field, _, value in _decode_fields(data): - match field: - case 1: - firmware = _parse_firmware_info(value) - case 2: - base = _parse_firmware_info(value) - if firmware is None or base is None or not firmware.url or not base.url: +def _firmware_info(message: t.Any) -> FirmwareInfo: + return FirmwareInfo( + version=message.version, + url=message.url, + md5=message.md5, + ) + + +def _update_info(result: t.Any) -> UpdateInfo | None: + if not result.firmware.url or not result.base.url: return None - return UpdateInfo(firmware=firmware, base=base) + return UpdateInfo( + firmware=_firmware_info(result.firmware), + base=_firmware_info(result.base), + ) async def check_update( @@ -246,33 +161,29 @@ async def check_update( ) -> UpdateInfo | None: """Ask the update server for the latest release for `product_id`. - `firmware_version` is the currently installed internal version; leaving it at 0 - always returns the latest. Returns `None` if the server reports no update. + `firmware_version` is the currently installed internal version. The server + returns the latest release regardless of its value, so it has no effect in + practice. Returns `None` if the server reports no update. - Requires `grpcio`. + Requires `grpcio` and `protobuf`. """ grpc = _require("grpc", "grpcio") + from . import _benshikj_pb2, _benshikj_pb2_grpc - request = _encode_varint_field(1, product_id) - if firmware_version: - request += _encode_varint_field(2, firmware_version) + request = _benshikj_pb2.CheckFirmwareUpdateRequest( + product_id=product_id, + firmware_version=firmware_version, + ) credentials = grpc.ssl_channel_credentials() async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: - call = channel.unary_unary( - RPC_METHOD, - request_serializer=lambda x: x, - response_deserializer=lambda x: x, - ) + stub = _benshikj_pb2_grpc.DeviceManagementStub(channel) try: - response: bytes = await call(request, timeout=RPC_TIMEOUT) + result = await stub.CheckFirmwareUpdate(request, timeout=RPC_TIMEOUT) except grpc.aio.AioRpcError as e: raise RuntimeError(f"update check failed: {e.code()} {e.details()}") - if not response: - return None - - return _parse_check_result(response) + return _update_info(result) def oss_update_info( diff --git a/tests/test_firmware.py b/tests/test_firmware.py index c3bf1a8..8701c36 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -9,76 +9,52 @@ FirmwareBundle, FirmwareInfo, UpdateInfo, - _decode_fields, - _encode_varint, - _encode_varint_field, - _parse_check_result, + _update_info, assemble, oss_update_info, ) bsdiff4 = pytest.importorskip("bsdiff4") +pytest.importorskip("google.protobuf") +from benlink import _benshikj_pb2 # noqa: E402 -def test_encode_varint(): - assert _encode_varint(0) == b"\x00" - assert _encode_varint(1) == b"\x01" - assert _encode_varint(127) == b"\x7f" - assert _encode_varint(128) == b"\x80\x01" - assert _encode_varint(259) == b"\x83\x02" +def test_request_field_numbers(): + # The field numbers are the wire contract; the names are ours. + request = _benshikj_pb2.CheckFirmwareUpdateRequest(product_id=259) + assert request.SerializeToString() == b"\x08\x83\x02" -def test_encode_varint_field(): - # field 1, wire type 0, value 259 - assert _encode_varint_field(1, 259) == b"\x08\x83\x02" - -def test_decode_fields_roundtrip(): - data = _encode_varint_field(1, 259) + _encode_varint_field(2, 147) - assert [(f, w) for f, w, _ in _decode_fields(data)] == [(1, 0), (2, 0)] - - -def _string_field(field: int, value: str) -> bytes: - raw = value.encode() - return _encode_varint(field << 3 | 2) + _encode_varint(len(raw)) + raw - - -def _message_field(field: int, value: bytes) -> bytes: - return _encode_varint(field << 3 | 2) + _encode_varint(len(value)) + value - - -def test_parse_check_result(): - firmware = ( - _encode_varint_field(1, 147) - + _string_field(2, "https://example.invalid/patch.bin") - + _string_field(3, "0c0d095da50bebe664822adcb244834a") - ) - base = ( - _encode_varint_field(1, 1) - + _string_field(2, "https://example.invalid/base.zip") - + _string_field(3, "74b6d097d8d2d9d2d9fac88133198a08") - ) - - result = _parse_check_result( - _message_field(1, firmware) + _message_field(2, base) +def test_update_info(): + result = _benshikj_pb2.CheckFirmwareUpdateResult( + firmware=_benshikj_pb2.FirmwareInfo( + version=147, + url="https://example.invalid/patch.bin", + md5="0c0d095da50bebe664822adcb244834a", + ), + base=_benshikj_pb2.FirmwareInfo( + url="https://example.invalid/base.zip", + md5="74b6d097d8d2d9d2d9fac88133198a08", + ), ) - assert result == UpdateInfo( + assert _update_info(result) == UpdateInfo( firmware=FirmwareInfo( version=147, url="https://example.invalid/patch.bin", md5="0c0d095da50bebe664822adcb244834a", ), base=FirmwareInfo( - version=1, + version=0, url="https://example.invalid/base.zip", md5="74b6d097d8d2d9d2d9fac88133198a08", ), ) -def test_parse_check_result_empty_means_no_update(): - assert _parse_check_result(b"") is None +def test_update_info_empty_means_no_update(): + assert _update_info(_benshikj_pb2.CheckFirmwareUpdateResult()) is None def test_oss_update_info(): From 16d5af564e75522d6d4162b529a81814248207cd Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:18:00 -0700 Subject: [PATCH 18/85] move protobuf conversion onto FirmwareInfo and UpdateInfo --- src/benlink/firmware.py | 34 ++++++++++++++++------------------ tests/test_firmware.py | 9 ++++----- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/benlink/firmware.py b/src/benlink/firmware.py index b09652e..424438d 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware.py @@ -101,12 +101,27 @@ class FirmwareInfo(t.NamedTuple): url: str md5: str + @classmethod + def from_protocol(cls, info: t.Any) -> FirmwareInfo: + """@private (Protocol helper)""" + return cls(version=info.version, url=info.url, md5=info.md5) + class UpdateInfo(t.NamedTuple): """The patch and base image that together make up a firmware release.""" firmware: FirmwareInfo base: FirmwareInfo + @classmethod + def from_protocol(cls, result: t.Any) -> UpdateInfo | None: + """@private (Protocol helper)""" + if not result.firmware.url or not result.base.url: + return None + return cls( + firmware=FirmwareInfo.from_protocol(result.firmware), + base=FirmwareInfo.from_protocol(result.base), + ) + class FirmwareBundle(t.NamedTuple): """An assembled, ready-to-flash firmware image.""" @@ -138,23 +153,6 @@ def save(self, path: str) -> None: ##################### # Finding an update -def _firmware_info(message: t.Any) -> FirmwareInfo: - return FirmwareInfo( - version=message.version, - url=message.url, - md5=message.md5, - ) - - -def _update_info(result: t.Any) -> UpdateInfo | None: - if not result.firmware.url or not result.base.url: - return None - return UpdateInfo( - firmware=_firmware_info(result.firmware), - base=_firmware_info(result.base), - ) - - async def check_update( product_id: int, firmware_version: int = 0, @@ -183,7 +181,7 @@ async def check_update( except grpc.aio.AioRpcError as e: raise RuntimeError(f"update check failed: {e.code()} {e.details()}") - return _update_info(result) + return UpdateInfo.from_protocol(result) def oss_update_info( diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 8701c36..81cd461 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -9,7 +9,6 @@ FirmwareBundle, FirmwareInfo, UpdateInfo, - _update_info, assemble, oss_update_info, ) @@ -26,7 +25,7 @@ def test_request_field_numbers(): assert request.SerializeToString() == b"\x08\x83\x02" -def test_update_info(): +def test_update_info_from_protocol(): result = _benshikj_pb2.CheckFirmwareUpdateResult( firmware=_benshikj_pb2.FirmwareInfo( version=147, @@ -39,7 +38,7 @@ def test_update_info(): ), ) - assert _update_info(result) == UpdateInfo( + assert UpdateInfo.from_protocol(result) == UpdateInfo( firmware=FirmwareInfo( version=147, url="https://example.invalid/patch.bin", @@ -53,8 +52,8 @@ def test_update_info(): ) -def test_update_info_empty_means_no_update(): - assert _update_info(_benshikj_pb2.CheckFirmwareUpdateResult()) is None +def test_update_info_from_protocol_empty_means_no_update(): + assert UpdateInfo.from_protocol(_benshikj_pb2.CheckFirmwareUpdateResult()) is None def test_oss_update_info(): From be3d1022355c16808f9db48743bdb9fa24db0618 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:22:13 -0700 Subject: [PATCH 19/85] move firmware into its own package and share ImmutableBaseModel via common --- Makefile | 2 +- src/benlink/command.py | 9 +- src/benlink/common.py | 16 ++ .../{firmware.py => firmware/__init__.py} | 167 +---------------- src/benlink/firmware/__main__.py | 177 ++++++++++++++++++ src/benlink/{ => firmware}/_benshikj.proto | 0 src/benlink/{ => firmware}/_benshikj_pb2.py | 24 +-- src/benlink/{ => firmware}/_benshikj_pb2.pyi | 0 .../{ => firmware}/_benshikj_pb2_grpc.py | 16 +- tests/test_firmware.py | 4 +- 10 files changed, 222 insertions(+), 193 deletions(-) create mode 100644 src/benlink/common.py rename src/benlink/{firmware.py => firmware/__init__.py} (61%) create mode 100644 src/benlink/firmware/__main__.py rename src/benlink/{ => firmware}/_benshikj.proto (100%) rename src/benlink/{ => firmware}/_benshikj_pb2.py (68%) rename src/benlink/{ => firmware}/_benshikj_pb2.pyi (100%) rename src/benlink/{ => firmware}/_benshikj_pb2_grpc.py (77%) diff --git a/Makefile b/Makefile index ab4ed9e..10c4750 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ all: docs proto: cd src && python -m grpc_tools.protoc -I. \ --python_out=. --pyi_out=. --grpc_python_out=. \ - benlink/_benshikj.proto + benlink/firmware/_benshikj.proto docs: pdoc ./src/benlink -o docs --logo /logo.svg diff --git a/src/benlink/command.py b/src/benlink/command.py index 8773078..c570a91 100644 --- a/src/benlink/command.py +++ b/src/benlink/command.py @@ -35,8 +35,8 @@ from __future__ import annotations import typing as t import asyncio -from pydantic import BaseModel, ConfigDict from . import protocol as p +from .common import ImmutableBaseModel from .link import CommandLink, BleCommandLink, RfcommCommandLink from datetime import datetime @@ -241,13 +241,6 @@ async def __aexit__( await self.disconnect() -class ImmutableBaseModel(BaseModel): - """@private (A base class for immutable data objects)""" - - model_config = ConfigDict(frozen=True) - """@private""" - - def command_message_to_protocol(m: CommandMessage) -> p.Message: """@private (Protocol helper)""" match m: diff --git a/src/benlink/common.py b/src/benlink/common.py new file mode 100644 index 0000000..e3e29e0 --- /dev/null +++ b/src/benlink/common.py @@ -0,0 +1,16 @@ +""" +Shared building blocks for benlink's data objects. + +Kept separate from `benlink.command` so that modules which don't talk to a radio +(e.g. `benlink.firmware`) can use them without pulling in the Bluetooth stack. +""" + +from __future__ import annotations +from pydantic import BaseModel, ConfigDict + + +class ImmutableBaseModel(BaseModel): + """@private (A base class for immutable data objects)""" + + model_config = ConfigDict(frozen=True) + """@private""" diff --git a/src/benlink/firmware.py b/src/benlink/firmware/__init__.py similarity index 61% rename from src/benlink/firmware.py rename to src/benlink/firmware/__init__.py index 424438d..8d7c637 100644 --- a/src/benlink/firmware.py +++ b/src/benlink/firmware/__init__.py @@ -44,14 +44,14 @@ from __future__ import annotations import typing as t -import argparse import asyncio import hashlib import io -import sys import urllib.request import zipfile +from ..common import ImmutableBaseModel + OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" """@private""" @@ -95,7 +95,7 @@ def _require(module: str, package: str): ##################### # Data -class FirmwareInfo(t.NamedTuple): +class FirmwareInfo(ImmutableBaseModel): """One downloadable artifact (either the patch or the base image).""" version: int url: str @@ -107,7 +107,7 @@ def from_protocol(cls, info: t.Any) -> FirmwareInfo: return cls(version=info.version, url=info.url, md5=info.md5) -class UpdateInfo(t.NamedTuple): +class UpdateInfo(ImmutableBaseModel): """The patch and base image that together make up a firmware release.""" firmware: FirmwareInfo base: FirmwareInfo @@ -123,7 +123,7 @@ def from_protocol(cls, result: t.Any) -> UpdateInfo | None: ) -class FirmwareBundle(t.NamedTuple): +class FirmwareBundle(ImmutableBaseModel): """An assembled, ready-to-flash firmware image.""" data: bytes update_info: UpdateInfo @@ -300,160 +300,3 @@ async def fetch_firmware( if update_info is None: return None return await download_firmware(update_info, progress) - - -##################### -# CLI - -def _print_progress(label: str, done: int, total: int) -> None: - pct = f"{100 * done // total}%" if total else f"{done} bytes" - print(f"\r{label}: {pct}", end="", file=sys.stderr, flush=True) - - -def _print_firmware_info(label: str, info: FirmwareInfo) -> None: - # The server populates version for the patch but not for the base image. - print(f"{label} v{info.version}" if info.version else label) - print(f" url {info.url}") - if info.md5: - print(f" md5 {info.md5}") - - -def _print_update_info(info: UpdateInfo) -> None: - _print_firmware_info("firmware", info.firmware) - _print_firmware_info("base", info.base) - - -def _resolve_product(args: argparse.Namespace) -> t.Tuple[int | None, str]: - """Resolve `--product` into a product id and patch name, letting the explicit - `--product-id` / `--patch-name` flags override either half.""" - product_id, patch_name = None, DEFAULT_PATCH_NAME - - if getattr(args, "product", None): - product_id, patch_name = PRODUCTS[args.product] - - if getattr(args, "product_id", None) is not None: - product_id = args.product_id - if getattr(args, "patch_name", None) is not None: - patch_name = args.patch_name - - return product_id, patch_name - - -def _require_product_id(product_id: int | None) -> int: - if product_id is None: - raise RuntimeError( - "a product is required: pass --product " - f"({', '.join(PRODUCTS)}) or --product-id" - ) - return product_id - - -async def _cmd_check(args: argparse.Namespace) -> int: - product_id, _ = _resolve_product(args) - info = await check_update(_require_product_id(product_id), - args.firmware_version) - if info is None: - print("no update available") - return 1 - _print_update_info(info) - return 0 - - -async def _cmd_fetch(args: argparse.Namespace) -> int: - product_id, patch_name = _resolve_product(args) - - if args.version is None: - info = await check_update(_require_product_id(product_id), - args.firmware_version) - if info is None: - print("no update available") - return 1 - else: - info = oss_update_info(args.version, patch_name, args.base_version) - - base = None - if args.base: - with open(args.base, "rb") as f: - base = f.read() - - _print_update_info(info) - sys.stdout.flush() - - bundle = await download_firmware(info, _print_progress, base) - print(file=sys.stderr) - - bundle.save(args.output) - print(f"wrote {args.output} ({bundle.size} bytes, md5 {bundle.md5})") - return 0 - - -async def _cmd_assemble(args: argparse.Namespace) -> int: - with open(args.base, "rb") as f: - base = f.read() - with open(args.patch, "rb") as f: - patch = f.read() - - data = assemble(base, patch) - with open(args.output, "wb") as f: - f.write(data) - - print(f"wrote {args.output} ({len(data)} bytes, " - f"md5 {hashlib.md5(data).hexdigest()})") - return 0 - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="python -m benlink.firmware", - description="Download and assemble Benshi radio firmware.", - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - check = subparsers.add_parser( - "check", help="ask the update server for the latest release") - check_product = check.add_mutually_exclusive_group(required=True) - check_product.add_argument("--product", choices=sorted(PRODUCTS)) - check_product.add_argument("--product-id", type=int, - help="from GET_DEV_INFO, for radios not listed above") - check.add_argument("--firmware-version", type=int, default=0, - help="currently installed internal version (default: 0)") - check.set_defaults(run=_cmd_check) - - fetch = subparsers.add_parser( - "fetch", help="download and assemble a firmware image") - source = fetch.add_mutually_exclusive_group(required=True) - source.add_argument("--product", choices=sorted(PRODUCTS), - help="ask the update server for this radio's latest release") - source.add_argument("--product-id", type=int, - help="as --product, for radios not listed above") - source.add_argument("--version", type=int, - help="fetch a known version directly, without the server") - fetch.add_argument("--firmware-version", type=int, default=0) - fetch.add_argument("--patch-name", - help=f"only used with --version (default: " - f"{DEFAULT_PATCH_NAME})") - fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, - help=f"default: {DEFAULT_BASE_VERSION}") - fetch.add_argument("--base", - help="local base image to reuse instead of downloading it") - fetch.add_argument("-o", "--output", required=True) - fetch.set_defaults(run=_cmd_fetch) - - assemble_cmd = subparsers.add_parser( - "assemble", help="assemble from local files (offline)") - assemble_cmd.add_argument("--base", required=True, - help="base image, raw or zipped") - assemble_cmd.add_argument("--patch", required=True) - assemble_cmd.add_argument("-o", "--output", required=True) - assemble_cmd.set_defaults(run=_cmd_assemble) - - return parser - - -if __name__ == "__main__": - args = _parser().parse_args() - try: - sys.exit(asyncio.run(args.run(args))) - except (RuntimeError, ImportError, OSError) as e: - print(f"error: {e}", file=sys.stderr) - sys.exit(1) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py new file mode 100644 index 0000000..68c25d6 --- /dev/null +++ b/src/benlink/firmware/__main__.py @@ -0,0 +1,177 @@ +"""Command line interface for `benlink.firmware`. + +Run with `python -m benlink.firmware`. +""" + +from __future__ import annotations +import typing as t +import argparse +import asyncio +import hashlib +import sys + +from . import ( + DEFAULT_BASE_VERSION, + DEFAULT_PATCH_NAME, + PRODUCTS, + FirmwareInfo, + UpdateInfo, + assemble, + check_update, + download_firmware, + oss_update_info, +) + + +def _print_progress(label: str, done: int, total: int) -> None: + pct = f"{100 * done // total}%" if total else f"{done} bytes" + print(f"\r{label}: {pct}", end="", file=sys.stderr, flush=True) + + +def _print_firmware_info(label: str, info: FirmwareInfo) -> None: + # The server populates version for the patch but not for the base image. + print(f"{label} v{info.version}" if info.version else label) + print(f" url {info.url}") + if info.md5: + print(f" md5 {info.md5}") + + +def _print_update_info(info: UpdateInfo) -> None: + _print_firmware_info("firmware", info.firmware) + _print_firmware_info("base", info.base) + + +def _resolve_product(args: argparse.Namespace) -> t.Tuple[int | None, str]: + """Resolve `--product` into a product id and patch name, letting the explicit + `--product-id` / `--patch-name` flags override either half.""" + product_id, patch_name = None, DEFAULT_PATCH_NAME + + if getattr(args, "product", None): + product_id, patch_name = PRODUCTS[args.product] + + if getattr(args, "product_id", None) is not None: + product_id = args.product_id + if getattr(args, "patch_name", None) is not None: + patch_name = args.patch_name + + return product_id, patch_name + + +def _require_product_id(product_id: int | None) -> int: + if product_id is None: + raise RuntimeError( + "a product is required: pass --product " + f"({', '.join(PRODUCTS)}) or --product-id" + ) + return product_id + + +async def _cmd_check(args: argparse.Namespace) -> int: + product_id, _ = _resolve_product(args) + info = await check_update(_require_product_id(product_id), + args.firmware_version) + if info is None: + print("no update available") + return 1 + _print_update_info(info) + return 0 + + +async def _cmd_fetch(args: argparse.Namespace) -> int: + product_id, patch_name = _resolve_product(args) + + if args.version is None: + info = await check_update(_require_product_id(product_id), + args.firmware_version) + if info is None: + print("no update available") + return 1 + else: + info = oss_update_info(args.version, patch_name, args.base_version) + + base = None + if args.base: + with open(args.base, "rb") as f: + base = f.read() + + _print_update_info(info) + sys.stdout.flush() + + bundle = await download_firmware(info, _print_progress, base) + print(file=sys.stderr) + + bundle.save(args.output) + print(f"wrote {args.output} ({bundle.size} bytes, md5 {bundle.md5})") + return 0 + + +async def _cmd_assemble(args: argparse.Namespace) -> int: + with open(args.base, "rb") as f: + base = f.read() + with open(args.patch, "rb") as f: + patch = f.read() + + data = assemble(base, patch) + with open(args.output, "wb") as f: + f.write(data) + + print(f"wrote {args.output} ({len(data)} bytes, " + f"md5 {hashlib.md5(data).hexdigest()})") + return 0 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m benlink.firmware", + description="Download and assemble Benshi radio firmware.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + check = subparsers.add_parser( + "check", help="ask the update server for the latest release") + check_product = check.add_mutually_exclusive_group(required=True) + check_product.add_argument("--product", choices=sorted(PRODUCTS)) + check_product.add_argument("--product-id", type=int, + help="from GET_DEV_INFO, for radios not listed above") + check.add_argument("--firmware-version", type=int, default=0, + help="currently installed internal version (default: 0)") + check.set_defaults(run=_cmd_check) + + fetch = subparsers.add_parser( + "fetch", help="download and assemble a firmware image") + source = fetch.add_mutually_exclusive_group(required=True) + source.add_argument("--product", choices=sorted(PRODUCTS), + help="ask the update server for this radio's latest release") + source.add_argument("--product-id", type=int, + help="as --product, for radios not listed above") + source.add_argument("--version", type=int, + help="fetch a known version directly, without the server") + fetch.add_argument("--firmware-version", type=int, default=0) + fetch.add_argument("--patch-name", + help=f"only used with --version (default: " + f"{DEFAULT_PATCH_NAME})") + fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, + help=f"default: {DEFAULT_BASE_VERSION}") + fetch.add_argument("--base", + help="local base image to reuse instead of downloading it") + fetch.add_argument("-o", "--output", required=True) + fetch.set_defaults(run=_cmd_fetch) + + assemble_cmd = subparsers.add_parser( + "assemble", help="assemble from local files (offline)") + assemble_cmd.add_argument("--base", required=True, + help="base image, raw or zipped") + assemble_cmd.add_argument("--patch", required=True) + assemble_cmd.add_argument("-o", "--output", required=True) + assemble_cmd.set_defaults(run=_cmd_assemble) + + return parser + + +if __name__ == "__main__": + args = _parser().parse_args() + try: + sys.exit(asyncio.run(args.run(args))) + except (RuntimeError, ImportError, OSError) as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/benlink/_benshikj.proto b/src/benlink/firmware/_benshikj.proto similarity index 100% rename from src/benlink/_benshikj.proto rename to src/benlink/firmware/_benshikj.proto diff --git a/src/benlink/_benshikj_pb2.py b/src/benlink/firmware/_benshikj_pb2.py similarity index 68% rename from src/benlink/_benshikj_pb2.py rename to src/benlink/firmware/_benshikj_pb2.py index 7bc5445..a140aac 100644 --- a/src/benlink/_benshikj_pb2.py +++ b/src/benlink/firmware/_benshikj_pb2.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE -# source: benlink/_benshikj.proto +# source: benlink/firmware/_benshikj.proto # Protobuf Python Version: 7.35.0 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor @@ -15,7 +15,7 @@ 35, 0, '', - 'benlink/_benshikj.proto' + 'benlink/firmware/_benshikj.proto' ) # @@protoc_insertion_point(imports) @@ -24,19 +24,19 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x62\x65nlink/_benshikj.proto\x12\x08\x62\x65nshikj\"~\n\x1a\x43heckFirmwareUpdateRequest\x12\x12\n\nproduct_id\x18\x01 \x01(\x05\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\x05\x12\x0c\n\x04\x62\x65ta\x18\x03 \x01(\x08\x12\x0f\n\x07user_id\x18\x04 \x01(\x03\x12\x13\n\x0binvite_code\x18\x05 \x01(\x05\"f\n\x0c\x46irmwareInfo\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03md5\x18\x03 \x01(\t\x12\x15\n\rrelease_notes\x18\x04 \x01(\t\x12\x14\n\x0crelease_date\x18\x05 \x01(\t\"k\n\x19\x43heckFirmwareUpdateResult\x12(\n\x08\x66irmware\x18\x01 \x01(\x0b\x32\x16.benshikj.FirmwareInfo\x12$\n\x04\x62\x61se\x18\x02 \x01(\x0b\x32\x16.benshikj.FirmwareInfo2t\n\x10\x44\x65viceManagement\x12`\n\x13\x43heckFirmwareUpdate\x12$.benshikj.CheckFirmwareUpdateRequest\x1a#.benshikj.CheckFirmwareUpdateResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n benlink/firmware/_benshikj.proto\x12\x08\x62\x65nshikj\"~\n\x1a\x43heckFirmwareUpdateRequest\x12\x12\n\nproduct_id\x18\x01 \x01(\x05\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\x05\x12\x0c\n\x04\x62\x65ta\x18\x03 \x01(\x08\x12\x0f\n\x07user_id\x18\x04 \x01(\x03\x12\x13\n\x0binvite_code\x18\x05 \x01(\x05\"f\n\x0c\x46irmwareInfo\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03md5\x18\x03 \x01(\t\x12\x15\n\rrelease_notes\x18\x04 \x01(\t\x12\x14\n\x0crelease_date\x18\x05 \x01(\t\"k\n\x19\x43heckFirmwareUpdateResult\x12(\n\x08\x66irmware\x18\x01 \x01(\x0b\x32\x16.benshikj.FirmwareInfo\x12$\n\x04\x62\x61se\x18\x02 \x01(\x0b\x32\x16.benshikj.FirmwareInfo2t\n\x10\x44\x65viceManagement\x12`\n\x13\x43heckFirmwareUpdate\x12$.benshikj.CheckFirmwareUpdateRequest\x1a#.benshikj.CheckFirmwareUpdateResultb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'benlink._benshikj_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'benlink.firmware._benshikj_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_start=37 - _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_end=163 - _globals['_FIRMWAREINFO']._serialized_start=165 - _globals['_FIRMWAREINFO']._serialized_end=267 - _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_start=269 - _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_end=376 - _globals['_DEVICEMANAGEMENT']._serialized_start=378 - _globals['_DEVICEMANAGEMENT']._serialized_end=494 + _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_start=46 + _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_end=172 + _globals['_FIRMWAREINFO']._serialized_start=174 + _globals['_FIRMWAREINFO']._serialized_end=276 + _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_start=278 + _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_end=385 + _globals['_DEVICEMANAGEMENT']._serialized_start=387 + _globals['_DEVICEMANAGEMENT']._serialized_end=503 # @@protoc_insertion_point(module_scope) diff --git a/src/benlink/_benshikj_pb2.pyi b/src/benlink/firmware/_benshikj_pb2.pyi similarity index 100% rename from src/benlink/_benshikj_pb2.pyi rename to src/benlink/firmware/_benshikj_pb2.pyi diff --git a/src/benlink/_benshikj_pb2_grpc.py b/src/benlink/firmware/_benshikj_pb2_grpc.py similarity index 77% rename from src/benlink/_benshikj_pb2_grpc.py rename to src/benlink/firmware/_benshikj_pb2_grpc.py index 47ac478..9e28560 100644 --- a/src/benlink/_benshikj_pb2_grpc.py +++ b/src/benlink/firmware/_benshikj_pb2_grpc.py @@ -3,7 +3,7 @@ import grpc import warnings -from benlink import _benshikj_pb2 as benlink_dot___benshikj__pb2 +from benlink.firmware import _benshikj_pb2 as benlink_dot_firmware_dot___benshikj__pb2 GRPC_GENERATED_VERSION = '1.82.1' GRPC_VERSION = grpc.__version__ @@ -18,7 +18,7 @@ if _version_not_supported: raise RuntimeError( f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in benlink/_benshikj_pb2_grpc.py depends on' + + ' but the generated code in benlink/firmware/_benshikj_pb2_grpc.py depends on' + f' grpcio>={GRPC_GENERATED_VERSION}.' + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' @@ -36,8 +36,8 @@ def __init__(self, channel): """ self.CheckFirmwareUpdate = channel.unary_unary( '/benshikj.DeviceManagement/CheckFirmwareUpdate', - request_serializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, - response_deserializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, + request_serializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, + response_deserializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, _registered_method=True) @@ -55,8 +55,8 @@ def add_DeviceManagementServicer_to_server(servicer, server): rpc_method_handlers = { 'CheckFirmwareUpdate': grpc.unary_unary_rpc_method_handler( servicer.CheckFirmwareUpdate, - request_deserializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.FromString, - response_serializer=benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.SerializeToString, + request_deserializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.FromString, + response_serializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -84,8 +84,8 @@ def CheckFirmwareUpdate(request, request, target, '/benshikj.DeviceManagement/CheckFirmwareUpdate', - benlink_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, - benlink_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, + benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, + benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, options, channel_credentials, insecure, diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 81cd461..ad6c6fa 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -16,7 +16,7 @@ bsdiff4 = pytest.importorskip("bsdiff4") pytest.importorskip("google.protobuf") -from benlink import _benshikj_pb2 # noqa: E402 +from benlink.firmware import _benshikj_pb2 # noqa: E402 def test_request_field_numbers(): @@ -88,7 +88,7 @@ def test_assemble_rejects_bad_patch_magic(): def test_resolve_product(): from argparse import Namespace - from benlink.firmware import PRODUCTS, _resolve_product + from benlink.firmware.__main__ import _resolve_product assert _resolve_product( Namespace(product="UV_PRO", product_id=None, patch_name=None) From 19256e6d46550a91c9d5420cc01769fba7e438eb Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 20:29:05 -0700 Subject: [PATCH 20/85] verify firmware md5s against extracted base and assembled image --- src/benlink/firmware/__init__.py | 48 +++++++++++++++++----------- src/benlink/firmware/__main__.py | 18 ++++++++--- src/benlink/firmware/_benshikj.proto | 2 ++ tests/test_firmware.py | 29 +++++++++++++++++ 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 8d7c637..738823f 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -235,26 +235,36 @@ def _verify(data: bytes, expected_md5: str, label: str) -> None: ) +def extract_base(base: bytes) -> bytes: + """Return the base image, unwrapping the zip it ships in if needed.""" + if base[:2] != b"PK": + return base + + with zipfile.ZipFile(io.BytesIO(base)) as zf: + names = [n for n in zf.namelist() if n.endswith(".bin")] + if not names: + raise RuntimeError("no .bin found in base zip") + return zf.read(names[0]) + + def assemble(base: bytes, patch: bytes) -> bytes: """Apply a BSDIFF40 patch to a base image. `base` may be either the raw base image or the zip it ships in. + + A patch carries no checksum of the base it was built against, so applying it to + the wrong base succeeds and silently yields a corrupt image. Patches are only + valid against the base image released alongside them — compare the result + against `UpdateInfo.firmware.md5` whenever it is known. """ bsdiff4 = _require("bsdiff4", "bsdiff4") - if base[:2] == b"PK": - with zipfile.ZipFile(io.BytesIO(base)) as zf: - names = [n for n in zf.namelist() if n.endswith(".bin")] - if not names: - raise RuntimeError("no .bin found in base zip") - base = zf.read(names[0]) - if patch[:8] != b"BSDIFF40": raise RuntimeError( f"unexpected patch magic {patch[:8]!r}, expected b'BSDIFF40'" ) - return bsdiff4.patch(base, patch) + return bsdiff4.patch(extract_base(base), patch) async def download_firmware( @@ -264,10 +274,10 @@ async def download_firmware( ) -> FirmwareBundle: """Download the patch and base image and assemble them. - The base image is shared across radios and releases, so pass `base` to reuse a - local copy instead of downloading it again. - - Downloaded artifacts are checked against the server's md5s when available. + Pass `base` to reuse a local copy instead of downloading it again. Note that + base images are revised over time and a patch only applies to the one released + with it, so a stale local copy will produce a corrupt image — which is caught + here only because the assembled result is checked against the server's md5. Requires `bsdiff4`. """ @@ -277,17 +287,19 @@ async def download_firmware( _download, update_info.firmware.url, "patch", progress), asyncio.to_thread(_download, update_info.base.url, "base", progress), ) - _verify(base, update_info.base.md5, "base") else: patch = await asyncio.to_thread( _download, update_info.firmware.url, "patch", progress) - _verify(patch, update_info.firmware.md5, "patch") + # The server's md5s describe the extracted base and the assembled firmware — + # neither the base zip nor the patch file as downloaded. + base = extract_base(base) + _verify(base, update_info.base.md5, "base image") - return FirmwareBundle( - data=await asyncio.to_thread(assemble, base, patch), - update_info=update_info, - ) + data = await asyncio.to_thread(assemble, base, patch) + _verify(data, update_info.firmware.md5, "assembled firmware") + + return FirmwareBundle(data=data, update_info=update_info) async def fetch_firmware( diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 68c25d6..1946545 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -23,9 +23,19 @@ ) -def _print_progress(label: str, done: int, total: int) -> None: - pct = f"{100 * done // total}%" if total else f"{done} bytes" - print(f"\r{label}: {pct}", end="", file=sys.stderr, flush=True) +def _make_progress() -> t.Callable[[str, int, int], None]: + """Render concurrent downloads as one updating line.""" + state: t.Dict[str, t.Tuple[int, int]] = {} + + def progress(label: str, done: int, total: int) -> None: + state[label] = (done, total) + line = " ".join( + f"{k} {100 * d // n}%" if n else f"{k} {d}B" + for k, (d, n) in state.items() + ) + print(f"\r{line}", end="", file=sys.stderr, flush=True) + + return progress def _print_firmware_info(label: str, info: FirmwareInfo) -> None: @@ -97,7 +107,7 @@ async def _cmd_fetch(args: argparse.Namespace) -> int: _print_update_info(info) sys.stdout.flush() - bundle = await download_firmware(info, _print_progress, base) + bundle = await download_firmware(info, _make_progress(), base) print(file=sys.stderr) bundle.save(args.output) diff --git a/src/benlink/firmware/_benshikj.proto b/src/benlink/firmware/_benshikj.proto index 734b0e9..9ca5067 100644 --- a/src/benlink/firmware/_benshikj.proto +++ b/src/benlink/firmware/_benshikj.proto @@ -20,6 +20,8 @@ message CheckFirmwareUpdateRequest { message FirmwareInfo { int32 version = 1; string url = 2; + // Not the md5 of the file at `url`: for the patch this is the md5 of the + // assembled firmware, and for the base it is the md5 of the .bin inside the zip. string md5 = 3; string release_notes = 4; string release_date = 5; diff --git a/tests/test_firmware.py b/tests/test_firmware.py index ad6c6fa..dffec3b 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -10,6 +10,7 @@ FirmwareInfo, UpdateInfo, assemble, + extract_base, oss_update_info, ) @@ -80,6 +81,34 @@ def test_assemble_zipped_base(): assert assemble(buf.getvalue(), bsdiff4.diff(base, expected)) == expected +def test_extract_base(): + raw = b"not a zip" + assert extract_base(raw) == raw + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("upgrade_base.bin", b"inner") + assert extract_base(buf.getvalue()) == b"inner" + + +def test_extract_base_rejects_zip_without_bin(): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("readme.txt", b"nope") + with pytest.raises(RuntimeError, match="no .bin found"): + extract_base(buf.getvalue()) + + +def test_assemble_against_wrong_base_is_not_detected(): + # BSDIFF40 carries no checksum of its source, so the wrong base yields a + # plausible but corrupt image. Callers must verify the assembled result. + base = b"the quick brown fox" * 100 + other = b"a completely different base" * 100 + patch = bsdiff4.diff(base, b"target" * 100) + + assert assemble(other, patch) != b"target" * 100 + + def test_assemble_rejects_bad_patch_magic(): with pytest.raises(RuntimeError, match="unexpected patch magic"): assemble(b"base", b"NOTAPATCH" + b"\x00" * 32) From 413ef8bd11fb64e6a753e0bdbd46ebf73ab39e62 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:01:56 -0700 Subject: [PATCH 21/85] restructure firmware cli around a guided update flow --- src/benlink/firmware/__init__.py | 80 +++++--- src/benlink/firmware/__main__.py | 305 ++++++++++++++++++++++++------- tests/test_firmware.py | 14 ++ 3 files changed, 312 insertions(+), 87 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 738823f..b4ca4f7 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -78,8 +78,21 @@ DEFAULT_PATCH_NAME = PRODUCTS["VR_N76"][1] """@private""" -DEFAULT_BASE_VERSION = 1 -"""Base image version. Independent of the firmware version; shared across releases.""" +BASE_IMAGES: t.Dict[str, str] = { + "original": "upgrade_base.bin.zip", + "1": "upgrade_base_v1.bin.zip", +} +"""The base images a patch can be built against, as `name: filename`. + +A patch carries no checksum of its source, so pairing it with the wrong base produces +a corrupt image with no error (see `assemble`). Known pairings, from flash captures and +from the update server: patch v120, v121 and v128 use `original`; v147 uses `1`. Where +the changeover happened is not known — the server only publishes metadata for the +current release. +""" + +DEFAULT_BASE_IMAGE = "1" +"""@private""" def _require(module: str, package: str): @@ -184,34 +197,56 @@ async def check_update( return UpdateInfo.from_protocol(result) +def oss_patch_url(version: int, patch_name: str = DEFAULT_PATCH_NAME) -> str: + """URL of a patch in the object store.""" + return f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin" + + +def oss_base_url(base_image: str = DEFAULT_BASE_IMAGE) -> str: + """URL of a base image in the object store. `base_image` is a key of + `BASE_IMAGES`.""" + if base_image not in BASE_IMAGES: + raise RuntimeError( + f"unknown base image {base_image!r}, expected one of " + f"{', '.join(BASE_IMAGES)}" + ) + return f"{OSS_BASE_URL}/{BASE_IMAGES[base_image]}" + + def oss_update_info( version: int, patch_name: str = DEFAULT_PATCH_NAME, - base_version: int = DEFAULT_BASE_VERSION, + base_image: str = DEFAULT_BASE_IMAGE, ) -> UpdateInfo: """Construct object-store URLs for a known version, without contacting the update server. - No md5s are available this way, so an image assembled from these URLs cannot be - verified against the vendor's own checksums. + No md5s are available this way, so the result cannot be verified — and since a + patch only applies to the base it shipped with, picking the wrong `base_image` + yields a corrupt image silently. See `BASE_IMAGES`. """ return UpdateInfo( firmware=FirmwareInfo( version=version, - url=f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin", - md5="", - ), - base=FirmwareInfo( - version=base_version, - url=f"{OSS_BASE_URL}/upgrade_base_v{base_version}.bin.zip", + url=oss_patch_url(version, patch_name), md5="", ), + base=FirmwareInfo(version=0, url=oss_base_url(base_image), md5=""), ) ##################### # Downloading and assembling +async def download( + url: str, + label: str = "download", + progress: ProgressCallback | None = None, +) -> bytes: + """Download a single artifact.""" + return await asyncio.to_thread(_download, url, label, progress) + + def _download(url: str, label: str, progress: ProgressCallback | None) -> bytes: with urllib.request.urlopen(url) as response: total = int(response.headers.get("Content-Length", 0)) @@ -270,26 +305,19 @@ def assemble(base: bytes, patch: bytes) -> bytes: async def download_firmware( update_info: UpdateInfo, progress: ProgressCallback | None = None, - base: bytes | None = None, ) -> FirmwareBundle: - """Download the patch and base image and assemble them. + """Download the patch and base image named by `update_info` and assemble them. - Pass `base` to reuse a local copy instead of downloading it again. Note that - base images are revised over time and a patch only applies to the one released - with it, so a stale local copy will produce a corrupt image — which is caught - here only because the assembled result is checked against the server's md5. + Both are always fetched fresh. Base images are revised over time and a patch + only applies to the one released with it, so reusing a local copy risks pairing + a patch with a base it was never built against. Requires `bsdiff4`. """ - if base is None: - patch, base = await asyncio.gather( - asyncio.to_thread( - _download, update_info.firmware.url, "patch", progress), - asyncio.to_thread(_download, update_info.base.url, "base", progress), - ) - else: - patch = await asyncio.to_thread( - _download, update_info.firmware.url, "patch", progress) + patch, base = await asyncio.gather( + asyncio.to_thread(_download, update_info.firmware.url, "patch", progress), + asyncio.to_thread(_download, update_info.base.url, "base", progress), + ) # The server's md5s describe the extracted base and the assembled firmware — # neither the base zip nor the patch file as downloaded. diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 1946545..db82804 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -8,21 +8,33 @@ import argparse import asyncio import hashlib +import os import sys +import tempfile from . import ( - DEFAULT_BASE_VERSION, + BASE_IMAGES, DEFAULT_PATCH_NAME, PRODUCTS, FirmwareInfo, UpdateInfo, assemble, check_update, + download, download_firmware, - oss_update_info, + extract_base, + oss_base_url, + oss_patch_url, ) +##################### +# Output + +def _out(message: str = "") -> None: + print(message, file=sys.stderr) + + def _make_progress() -> t.Callable[[str, int, int], None]: """Render concurrent downloads as one updating line.""" state: t.Dict[str, t.Tuple[int, int]] = {} @@ -33,23 +45,86 @@ def progress(label: str, done: int, total: int) -> None: f"{k} {100 * d // n}%" if n else f"{k} {d}B" for k, (d, n) in state.items() ) - print(f"\r{line}", end="", file=sys.stderr, flush=True) + print(f"\r {line}", end="", file=sys.stderr, flush=True) return progress -def _print_firmware_info(label: str, info: FirmwareInfo) -> None: - # The server populates version for the patch but not for the base image. - print(f"{label} v{info.version}" if info.version else label) - print(f" url {info.url}") - if info.md5: - print(f" md5 {info.md5}") +def _print_verdict(data: bytes, expected: str, source: str) -> None: + """Every image this tool produces reports whether it could be checked. + + An unverified image is the failure mode that bricks a radio quietly, so the + warning is never suppressed. + """ + md5 = hashlib.md5(data).hexdigest() + if not expected: + _out(f" md5 {md5} [!] unverified (no reference md5 available)") + elif md5 == expected: + _out(f" md5 {md5} ok, matches {source}") + else: + raise RuntimeError( + f"md5 mismatch against {source}: expected {expected}, got {md5}" + ) def _print_update_info(info: UpdateInfo) -> None: - _print_firmware_info("firmware", info.firmware) - _print_firmware_info("base", info.base) + def show(label: str, entry: FirmwareInfo) -> None: + # The server populates version for the patch but not for the base image. + _out(f" {label} v{entry.version}" if entry.version else f" {label}") + _out(f" url {entry.url}") + if entry.md5: + _out(f" md5 {entry.md5}") + + show("patch", info.firmware) + show("base", info.base) + + +def _write(path: str, data: bytes, force: bool) -> None: + if os.path.exists(path) and not force: + raise RuntimeError(f"{path} already exists (use --force to overwrite)") + with open(path, "wb") as f: + f.write(data) + print(path) + + +def _confirm(question: str, default_yes: bool, assume_yes: bool) -> bool: + if assume_yes: + return True + suffix = "[Y/n]" if default_yes else "[y/N]" + try: + answer = input(f"{question} {suffix} ").strip().lower() + except EOFError: + return False + return default_yes if not answer else answer.startswith("y") + +##################### +# Radio + +def _connection(args: argparse.Namespace): + # Imported lazily: everything except the radio commands works without a + # Bluetooth stack. + from ..command import CommandConnection + + if args.rfcomm is not None: + channel = "auto" if args.rfcomm == "auto" else int(args.rfcomm) + _out(f"Connecting over RFCOMM to {args.uuid} (channel {channel})...") + return CommandConnection.new_rfcomm(args.uuid, channel) + + _out(f"Connecting over BLE to {args.uuid}...") + return CommandConnection.new_ble(args.uuid) + + +def _print_device_info(info: t.Any) -> None: + _out(f" vendor {info.vendor_id}, product {info.product_id}") + # Whether this shares the update server's numbering (v87..v147) is unconfirmed, + # so it is reported as-is rather than compared against the server's version. + _out(f" firmware version {info.firmware_version}" + f", hardware version {info.hardware_version}") + + +##################### +# Products def _resolve_product(args: argparse.Namespace) -> t.Tuple[int | None, str]: """Resolve `--product` into a product id and patch name, letting the explicit @@ -76,42 +151,68 @@ def _require_product_id(product_id: int | None) -> int: return product_id +##################### +# Commands + +async def _cmd_info(args: argparse.Namespace) -> int: + async with _connection(args) as conn: + _print_device_info(await conn.get_device_info()) + return 0 + + async def _cmd_check(args: argparse.Namespace) -> int: product_id, _ = _resolve_product(args) info = await check_update(_require_product_id(product_id), args.firmware_version) if info is None: - print("no update available") - return 1 + _out("no update available") + return 2 _print_update_info(info) return 0 async def _cmd_fetch(args: argparse.Namespace) -> int: - product_id, patch_name = _resolve_product(args) - - if args.version is None: - info = await check_update(_require_product_id(product_id), - args.firmware_version) - if info is None: - print("no update available") - return 1 - else: - info = oss_update_info(args.version, patch_name, args.base_version) - - base = None - if args.base: - with open(args.base, "rb") as f: - base = f.read() + product_id, _ = _resolve_product(args) + info = await check_update(_require_product_id(product_id), + args.firmware_version) + if info is None: + _out("no update available") + return 2 _print_update_info(info) - sys.stdout.flush() - bundle = await download_firmware(info, _make_progress(), base) - print(file=sys.stderr) + bundle = await download_firmware(info, _make_progress()) + _out() + _print_verdict(bundle.data, info.firmware.md5, "the update server") + + _write(args.output, bundle.data, args.force) + return 0 + + +async def _cmd_download_patch(args: argparse.Namespace) -> int: + _, patch_name = _resolve_product(args) + url = oss_patch_url(args.version, patch_name) + _out(f" url {url}") - bundle.save(args.output) - print(f"wrote {args.output} ({bundle.size} bytes, md5 {bundle.md5})") + data = await download(url, "patch", _make_progress()) + _out() + _print_verdict(data, "", "") + + _write(args.output, data, args.force) + return 0 + + +async def _cmd_download_base(args: argparse.Namespace) -> int: + url = oss_base_url(args.version) + _out(f" url {url}") + + data = await download(url, "base", _make_progress()) + _out() + + extracted = extract_base(data) + _out(f" extracted md5 {hashlib.md5(extracted).hexdigest()}") + + _write(args.output, extracted if args.extract else data, args.force) return 0 @@ -122,14 +223,70 @@ async def _cmd_assemble(args: argparse.Namespace) -> int: patch = f.read() data = assemble(base, patch) - with open(args.output, "wb") as f: - f.write(data) + _print_verdict(data, args.expect_md5 or "", "--expect-md5") + + _write(args.output, data, args.force) + return 0 + + +async def _cmd_update(args: argparse.Namespace) -> int: + async with _connection(args) as conn: + device_info = await conn.get_device_info() + _print_device_info(device_info) + + _out() + _out("Checking for updates...") + info = await check_update(device_info.product_id) + if info is None: + _out(" no update available") + return 2 + _print_update_info(info) - print(f"wrote {args.output} ({len(data)} bytes, " - f"md5 {hashlib.md5(data).hexdigest()})") + _out() + if not _confirm("Download and assemble?", True, args.yes): + return 0 + + bundle = await download_firmware(info, _make_progress()) + _out() + _out(f" assembled {bundle.size} bytes") + _print_verdict(bundle.data, info.firmware.md5, "the update server") + + directory = args.keep or tempfile.mkdtemp(prefix="benlink-fw-") + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, f"firmware-v{info.firmware.version}.bin") + _out() + _write(path, bundle.data, args.force) + + _out() + _out("Flashing is not implemented yet — see " + "https://github.com/khusmann/benlink/issues/10") + _out(f"The assembled image has been kept at {path}") return 0 +##################### +# Parser + +def _add_radio_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("uuid", help="radio device UUID, e.g. XX:XX:XX:XX:XX:XX") + parser.add_argument("--rfcomm", nargs="?", const="auto", default=None, + metavar="CHANNEL", + help="connect over RFCOMM instead of BLE") + + +def _add_product_args(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--product", choices=sorted(PRODUCTS)) + group.add_argument("--product-id", type=int, + help="from `info`, for radios not listed above") + + +def _add_output_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("-o", "--output", required=True) + parser.add_argument("--force", action="store_true", + help="overwrite an existing output file") + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="python -m benlink.firmware", @@ -137,42 +294,66 @@ def _parser() -> argparse.ArgumentParser: ) subparsers = parser.add_subparsers(dest="command", required=True) + update = subparsers.add_parser( + "update", help="guided upgrade: read the radio, fetch, assemble") + _add_radio_args(update) + update.add_argument("--yes", "-y", action="store_true", + help="accept all prompts") + update.add_argument("--keep", metavar="DIR", + help="write to DIR instead of a temporary directory") + update.add_argument("--force", action="store_true") + update.set_defaults(run=_cmd_update) + + info = subparsers.add_parser( + "info", help="read product id and versions from a radio") + _add_radio_args(info) + info.set_defaults(run=_cmd_info) + check = subparsers.add_parser( "check", help="ask the update server for the latest release") - check_product = check.add_mutually_exclusive_group(required=True) - check_product.add_argument("--product", choices=sorted(PRODUCTS)) - check_product.add_argument("--product-id", type=int, - help="from GET_DEV_INFO, for radios not listed above") + _add_product_args(check) check.add_argument("--firmware-version", type=int, default=0, - help="currently installed internal version (default: 0)") + help=argparse.SUPPRESS) check.set_defaults(run=_cmd_check) fetch = subparsers.add_parser( - "fetch", help="download and assemble a firmware image") - source = fetch.add_mutually_exclusive_group(required=True) - source.add_argument("--product", choices=sorted(PRODUCTS), - help="ask the update server for this radio's latest release") - source.add_argument("--product-id", type=int, - help="as --product, for radios not listed above") - source.add_argument("--version", type=int, - help="fetch a known version directly, without the server") - fetch.add_argument("--firmware-version", type=int, default=0) - fetch.add_argument("--patch-name", - help=f"only used with --version (default: " - f"{DEFAULT_PATCH_NAME})") - fetch.add_argument("--base-version", type=int, default=DEFAULT_BASE_VERSION, - help=f"default: {DEFAULT_BASE_VERSION}") - fetch.add_argument("--base", - help="local base image to reuse instead of downloading it") - fetch.add_argument("-o", "--output", required=True) + "fetch", help="check, download and assemble the latest release") + _add_product_args(fetch) + fetch.add_argument("--firmware-version", type=int, default=0, + help=argparse.SUPPRESS) + _add_output_args(fetch) fetch.set_defaults(run=_cmd_fetch) + patch = subparsers.add_parser( + "download-patch", help="download one patch by version") + patch.add_argument("--version", type=int, required=True) + patch_product = patch.add_mutually_exclusive_group(required=True) + patch_product.add_argument("--product", choices=sorted(PRODUCTS)) + patch_product.add_argument("--patch-name", + help=f"e.g. {DEFAULT_PATCH_NAME}") + _add_output_args(patch) + patch.set_defaults(run=_cmd_download_patch) + + base = subparsers.add_parser( + "download-base", help="download a base image") + # No default: a patch only applies to the base it shipped with, and picking + # the wrong one corrupts the result silently. + base.add_argument("--version", choices=sorted(BASE_IMAGES), required=True, + help="which base image; patches v120-v128 use 'original', " + "v147 uses '1'") + base.add_argument("--extract", action="store_true", + help="unwrap the zip and write the .bin") + _add_output_args(base) + base.set_defaults(run=_cmd_download_base) + assemble_cmd = subparsers.add_parser( - "assemble", help="assemble from local files (offline)") + "assemble", help="combine a base and a patch (offline)") assemble_cmd.add_argument("--base", required=True, help="base image, raw or zipped") assemble_cmd.add_argument("--patch", required=True) - assemble_cmd.add_argument("-o", "--output", required=True) + assemble_cmd.add_argument("--expect-md5", metavar="MD5", + help="verify the assembled image against a known md5") + _add_output_args(assemble_cmd) assemble_cmd.set_defaults(run=_cmd_assemble) return parser @@ -182,6 +363,8 @@ def _parser() -> argparse.ArgumentParser: args = _parser().parse_args() try: sys.exit(asyncio.run(args.run(args))) + except KeyboardInterrupt: + sys.exit(130) except (RuntimeError, ImportError, OSError) as e: print(f"error: {e}", file=sys.stderr) sys.exit(1) diff --git a/tests/test_firmware.py b/tests/test_firmware.py index dffec3b..4b983cb 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -11,6 +11,8 @@ UpdateInfo, assemble, extract_base, + oss_base_url, + oss_patch_url, oss_update_info, ) @@ -57,6 +59,18 @@ def test_update_info_from_protocol_empty_means_no_update(): assert UpdateInfo.from_protocol(_benshikj_pb2.CheckFirmwareUpdateResult()) is None +def test_oss_urls(): + assert oss_patch_url(147).endswith("/firmware/v147/patch_base_to_vr_n76.bin") + assert oss_patch_url(147, "custom").endswith("/firmware/v147/custom.bin") + assert oss_base_url("original").endswith("/upgrade_base.bin.zip") + assert oss_base_url("1").endswith("/upgrade_base_v1.bin.zip") + + +def test_oss_base_url_rejects_unknown_base(): + with pytest.raises(RuntimeError, match="unknown base image"): + oss_base_url("2") + + def test_oss_update_info(): info = oss_update_info(147) assert info.firmware.url.endswith("/firmware/v147/patch_base_to_vr_n76.bin") From e6deafaf54434da23171eeafe5e622dd177ab1e7 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:04:16 -0700 Subject: [PATCH 22/85] compare installed and latest firmware versions in update flow --- src/benlink/firmware/__main__.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index db82804..640c071 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -117,9 +117,7 @@ def _connection(args: argparse.Namespace): def _print_device_info(info: t.Any) -> None: _out(f" vendor {info.vendor_id}, product {info.product_id}") - # Whether this shares the update server's numbering (v87..v147) is unconfirmed, - # so it is reported as-is rather than compared against the server's version. - _out(f" firmware version {info.firmware_version}" + _out(f" firmware v{info.firmware_version}" f", hardware version {info.hardware_version}") @@ -240,10 +238,18 @@ async def _cmd_update(args: argparse.Namespace) -> int: if info is None: _out(" no update available") return 2 + + installed = device_info.firmware_version + latest = info.firmware.version + _out(f" latest v{latest} (you have v{installed})") _print_update_info(info) _out() - if not _confirm("Download and assemble?", True, args.yes): + if latest == installed: + question = f"Already on v{latest}. Download and assemble anyway?" + if not _confirm(question, False, args.yes): + return 0 + elif not _confirm("Download and assemble?", True, args.yes): return 0 bundle = await download_firmware(info, _make_progress()) From 930ceddc26d52ac9214a7ac061525feef15eae24 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:23:15 -0700 Subject: [PATCH 23/85] document the firmware update flow and expose it in the package namespace --- Makefile | 5 +- src/benlink/__init__.py | 3 +- src/benlink/firmware/__init__.py | 110 ++++++++++++++++++++----------- src/benlink/firmware/__main__.py | 2 +- 4 files changed, 80 insertions(+), 40 deletions(-) diff --git a/Makefile b/Makefile index 10c4750..8515553 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,10 @@ proto: benlink/firmware/_benshikj.proto docs: - pdoc ./src/benlink -o docs --logo /logo.svg + pdoc ./src/benlink \ + '!benlink.firmware._benshikj_pb2' \ + '!benlink.firmware._benshikj_pb2_grpc' \ + -o docs --logo /logo.svg cp ./assets/logo-transparent.svg docs/logo.svg preview-docs: diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 636bbf7..fc0a7fb 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -131,5 +131,6 @@ async def main(): from . import controller from . import command from . import audio +from . import firmware -__all__ = ['controller', 'command', 'audio'] \ No newline at end of file +__all__ = ['controller', 'command', 'audio', 'firmware'] \ No newline at end of file diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index b4ca4f7..16802fa 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -1,45 +1,79 @@ """ -# Overview +# Disclaimer -Firmware update support for Benshi radios. +**Use this at your own risk. I am not responsible for bricking your radio, or for +any other damage to your equipment.** This module is not endorsed by or affiliated +with Benshi, Vero, RadioOddity, BTech, or any other company. -This module is deliberately excluded from `benlink`'s default namespace. Flashing -firmware can brick a radio, so it must be imported explicitly: +Downloading and assembling an image is safe. Flashing one is not, and is not +implemented yet ([issue #10](https://github.com/khusmann/benlink/issues/10)). -```python -import benlink.firmware +# The intended flow + +Firmware ships as a shared **base image** plus a per-release **patch** in BSDIFF40 +format; assembling the two yields the image the radio expects. benlink +redistributes neither, and fetches both on request. + +One command walks the whole upgrade, prompting as it goes: + +```bash +python -m benlink.firmware update XX:XX:XX:XX:XX:XX ``` -Firmware is distributed as a shared **base image** plus a per-release **patch** in -BSDIFF40 format. Assembling the two yields the image the radio expects. Neither is -redistributed by benlink — both are fetched from the vendor's servers at the user's -request. +It reads the product id and installed version from the radio, asks the update +server for the latest release, downloads the patch and base, assembles them, and +checks the result against the server's md5. Because the server names both +artifacts, this path cannot pair a patch with the wrong base. -Two ways to find an image: +Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere +durable, `-y` to accept prompts. -1. Ask the vendor's update server what the latest release is for a given product id - (`check_update`). Requires `grpcio`, and returns md5s that let the assembled image - be verified. -2. Address the object store directly by version number (`oss_update_info`). Needs no - RPC and no product id, which keeps this module working if the update server - changes. +# The pieces -# CLI +Each step is also available alone, for archiving old releases or working away from +the radio. Everything but `info` avoids the Bluetooth stack. ```bash -python -m benlink.firmware check --product-id 259 -python -m benlink.firmware fetch --product-id 259 -o fw.bin -python -m benlink.firmware fetch --version 147 -o fw.bin -python -m benlink.firmware assemble --base upgrade_base.bin --patch patch.bin -o fw.bin +# which radio is this? +python -m benlink.firmware info XX:XX:XX:XX:XX:XX + +# what is the latest release? +python -m benlink.firmware check --product UV_PRO + +# that release, downloaded and assembled, without a radio +python -m benlink.firmware fetch --product UV_PRO -o fw.bin + +# one artifact at a time, for any version +python -m benlink.firmware download-patch --version 128 --product UV_PRO -o patch.bin +python -m benlink.firmware download-base --version original -o base.zip + +# combine them offline +python -m benlink.firmware assemble --base base.zip --patch patch.bin -o fw.bin ``` -`assemble` is fully offline. `check` and `fetch --product-id` contact the update -server; `fetch --version` contacts only the object store. +`--product` is a shorthand for the radios in `PRODUCTS`; `--product-id` works for +any radio, and `info` tells you yours. If yours isn't listed, please +[open an issue](https://github.com/khusmann/benlink/issues) with what `info` +reports so it can be added. + +# Verification + +A BSDIFF40 patch carries no checksum of the base it was built against, so pairing +a patch with the wrong base **succeeds silently** and produces a corrupt image of +plausible length. See `BASE_IMAGES` for the known pairings. + +The server publishes an md5 of the *assembled* image for the current release, so +`update` and `fetch` are checked end to end. Older releases have none; for those, +`assemble --expect-md5` accepts one from elsewhere, such as the `md5sum_tail` in a +packet capture of an official flash. Every command that writes an image says +whether it could be verified. # Notes -The product id is read from the radio via `GET_DEV_INFO` (`DeviceInfo.product_id`). -It is not unique across vendors — the VR-N76 and GA-5WB both report 259. +The product id comes from `GET_DEV_INFO` (`DeviceInfo.product_id`) and is not +unique across vendors: the VR-N76 and GA-5WB both report 259. +`DeviceInfo.firmware_version` shares the update server's numbering, so installed +and available versions compare directly. """ from __future__ import annotations @@ -70,7 +104,7 @@ """Known radios, as `name: (product_id, patch_name)`. Every patch name here was returned by the update server for the corresponding product -id. Note that 259 covers both the VR-N76 and the GA-5WB — they share a patch series, +id. Note that 259 covers both the VR-N76 and the GA-5WB, which share a patch series, confirmed by a GA-5WB flash capture whose `md5sum_tail` matches `patch_base_to_vr_n76.v120` assembled against the shared base. """ @@ -87,8 +121,8 @@ A patch carries no checksum of its source, so pairing it with the wrong base produces a corrupt image with no error (see `assemble`). Known pairings, from flash captures and from the update server: patch v120, v121 and v128 use `original`; v147 uses `1`. Where -the changeover happened is not known — the server only publishes metadata for the -current release. +the changeover happened is not known, because the server only publishes metadata for +the current release. """ DEFAULT_BASE_IMAGE = "1" @@ -192,7 +226,8 @@ async def check_update( try: result = await stub.CheckFirmwareUpdate(request, timeout=RPC_TIMEOUT) except grpc.aio.AioRpcError as e: - raise RuntimeError(f"update check failed: {e.code()} {e.details()}") + raise RuntimeError( + f"update check failed: {e.code()} {e.details()}") return UpdateInfo.from_protocol(result) @@ -221,9 +256,9 @@ def oss_update_info( """Construct object-store URLs for a known version, without contacting the update server. - No md5s are available this way, so the result cannot be verified — and since a - patch only applies to the base it shipped with, picking the wrong `base_image` - yields a corrupt image silently. See `BASE_IMAGES`. + No md5s are available this way, so the result cannot be verified. Since a patch + only applies to the base it shipped with, picking the wrong `base_image` yields a + corrupt image silently. See `BASE_IMAGES`. """ return UpdateInfo( firmware=FirmwareInfo( @@ -289,8 +324,8 @@ def assemble(base: bytes, patch: bytes) -> bytes: A patch carries no checksum of the base it was built against, so applying it to the wrong base succeeds and silently yields a corrupt image. Patches are only - valid against the base image released alongside them — compare the result - against `UpdateInfo.firmware.md5` whenever it is known. + valid against the base image released alongside them. Compare the result against + `UpdateInfo.firmware.md5` whenever it is known. """ bsdiff4 = _require("bsdiff4", "bsdiff4") @@ -315,11 +350,12 @@ async def download_firmware( Requires `bsdiff4`. """ patch, base = await asyncio.gather( - asyncio.to_thread(_download, update_info.firmware.url, "patch", progress), + asyncio.to_thread(_download, update_info.firmware.url, + "patch", progress), asyncio.to_thread(_download, update_info.base.url, "base", progress), ) - # The server's md5s describe the extracted base and the assembled firmware — + # The server's md5s describe the extracted base and the assembled firmware, # neither the base zip nor the patch file as downloaded. base = extract_base(base) _verify(base, update_info.base.md5, "base image") diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 640c071..611e244 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -264,7 +264,7 @@ async def _cmd_update(args: argparse.Namespace) -> int: _write(path, bundle.data, args.force) _out() - _out("Flashing is not implemented yet — see " + _out("Flashing is not implemented yet, see " "https://github.com/khusmann/benlink/issues/10") _out(f"The assembled image has been kept at {path}") return 0 From 7f67078911b090c981b8fe74a3f46df28af886ef Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:28:57 -0700 Subject: [PATCH 24/85] require explicit patch name and base image, make reference md5 optional --- src/benlink/firmware/__init__.py | 29 +++++++++++++---------------- src/benlink/firmware/__main__.py | 16 +++++++++------- tests/test_firmware.py | 15 +++++++++------ 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 16802fa..312cb60 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -109,9 +109,6 @@ `patch_base_to_vr_n76.v120` assembled against the shared base. """ -DEFAULT_PATCH_NAME = PRODUCTS["VR_N76"][1] -"""@private""" - BASE_IMAGES: t.Dict[str, str] = { "original": "upgrade_base.bin.zip", "1": "upgrade_base_v1.bin.zip", @@ -125,10 +122,6 @@ the current release. """ -DEFAULT_BASE_IMAGE = "1" -"""@private""" - - def _require(module: str, package: str): try: return __import__(module) @@ -146,12 +139,16 @@ class FirmwareInfo(ImmutableBaseModel): """One downloadable artifact (either the patch or the base image).""" version: int url: str - md5: str + md5: str | None + """md5 of the *assembled* image for a patch, or of the *extracted* base image. + + `None` when no reference md5 is available, which is the case for every release + but the current one.""" @classmethod def from_protocol(cls, info: t.Any) -> FirmwareInfo: """@private (Protocol helper)""" - return cls(version=info.version, url=info.url, md5=info.md5) + return cls(version=info.version, url=info.url, md5=info.md5 or None) class UpdateInfo(ImmutableBaseModel): @@ -232,12 +229,12 @@ async def check_update( return UpdateInfo.from_protocol(result) -def oss_patch_url(version: int, patch_name: str = DEFAULT_PATCH_NAME) -> str: +def oss_patch_url(version: int, patch_name: str) -> str: """URL of a patch in the object store.""" return f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin" -def oss_base_url(base_image: str = DEFAULT_BASE_IMAGE) -> str: +def oss_base_url(base_image: str) -> str: """URL of a base image in the object store. `base_image` is a key of `BASE_IMAGES`.""" if base_image not in BASE_IMAGES: @@ -250,8 +247,8 @@ def oss_base_url(base_image: str = DEFAULT_BASE_IMAGE) -> str: def oss_update_info( version: int, - patch_name: str = DEFAULT_PATCH_NAME, - base_image: str = DEFAULT_BASE_IMAGE, + patch_name: str, + base_image: str, ) -> UpdateInfo: """Construct object-store URLs for a known version, without contacting the update server. @@ -264,9 +261,9 @@ def oss_update_info( firmware=FirmwareInfo( version=version, url=oss_patch_url(version, patch_name), - md5="", + md5=None, ), - base=FirmwareInfo(version=0, url=oss_base_url(base_image), md5=""), + base=FirmwareInfo(version=0, url=oss_base_url(base_image), md5=None), ) @@ -295,7 +292,7 @@ def _download(url: str, label: str, progress: ProgressCallback | None) -> bytes: return b"".join(chunks) -def _verify(data: bytes, expected_md5: str, label: str) -> None: +def _verify(data: bytes, expected_md5: str | None, label: str) -> None: if not expected_md5: return actual = hashlib.md5(data).hexdigest() diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 611e244..c6e26d2 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -14,7 +14,6 @@ from . import ( BASE_IMAGES, - DEFAULT_PATCH_NAME, PRODUCTS, FirmwareInfo, UpdateInfo, @@ -50,7 +49,7 @@ def progress(label: str, done: int, total: int) -> None: return progress -def _print_verdict(data: bytes, expected: str, source: str) -> None: +def _print_verdict(data: bytes, expected: str | None, source: str) -> None: """Every image this tool produces reports whether it could be checked. An unverified image is the failure mode that bricks a radio quietly, so the @@ -124,10 +123,12 @@ def _print_device_info(info: t.Any) -> None: ##################### # Products -def _resolve_product(args: argparse.Namespace) -> t.Tuple[int | None, str]: +def _resolve_product( + args: argparse.Namespace, +) -> t.Tuple[int | None, str | None]: """Resolve `--product` into a product id and patch name, letting the explicit `--product-id` / `--patch-name` flags override either half.""" - product_id, patch_name = None, DEFAULT_PATCH_NAME + product_id, patch_name = None, None if getattr(args, "product", None): product_id, patch_name = PRODUCTS[args.product] @@ -189,12 +190,13 @@ async def _cmd_fetch(args: argparse.Namespace) -> int: async def _cmd_download_patch(args: argparse.Namespace) -> int: _, patch_name = _resolve_product(args) + assert patch_name is not None # the parser requires --product or --patch-name url = oss_patch_url(args.version, patch_name) _out(f" url {url}") data = await download(url, "patch", _make_progress()) _out() - _print_verdict(data, "", "") + _print_verdict(data, None, "") _write(args.output, data, args.force) return 0 @@ -221,7 +223,7 @@ async def _cmd_assemble(args: argparse.Namespace) -> int: patch = f.read() data = assemble(base, patch) - _print_verdict(data, args.expect_md5 or "", "--expect-md5") + _print_verdict(data, args.expect_md5, "--expect-md5") _write(args.output, data, args.force) return 0 @@ -336,7 +338,7 @@ def _parser() -> argparse.ArgumentParser: patch_product = patch.add_mutually_exclusive_group(required=True) patch_product.add_argument("--product", choices=sorted(PRODUCTS)) patch_product.add_argument("--patch-name", - help=f"e.g. {DEFAULT_PATCH_NAME}") + help="e.g. patch_base_to_vr_n76") _add_output_args(patch) patch.set_defaults(run=_cmd_download_patch) diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 4b983cb..0bbe4f4 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -4,7 +4,6 @@ import pytest from benlink.firmware import ( - DEFAULT_PATCH_NAME, PRODUCTS, FirmwareBundle, FirmwareInfo, @@ -60,7 +59,8 @@ def test_update_info_from_protocol_empty_means_no_update(): def test_oss_urls(): - assert oss_patch_url(147).endswith("/firmware/v147/patch_base_to_vr_n76.bin") + assert oss_patch_url(147, "patch_base_to_vr_n76").endswith( + "/firmware/v147/patch_base_to_vr_n76.bin") assert oss_patch_url(147, "custom").endswith("/firmware/v147/custom.bin") assert oss_base_url("original").endswith("/upgrade_base.bin.zip") assert oss_base_url("1").endswith("/upgrade_base_v1.bin.zip") @@ -72,10 +72,10 @@ def test_oss_base_url_rejects_unknown_base(): def test_oss_update_info(): - info = oss_update_info(147) + info = oss_update_info(147, "patch_base_to_vr_n76", "1") assert info.firmware.url.endswith("/firmware/v147/patch_base_to_vr_n76.bin") assert info.base.url.endswith("/upgrade_base_v1.bin.zip") - assert info.firmware.md5 == "" + assert info.firmware.md5 is None def test_assemble_raw_base(): @@ -148,7 +148,7 @@ def test_resolve_product(): assert _resolve_product( Namespace(product=None, product_id=None, patch_name=None) - ) == (None, DEFAULT_PATCH_NAME) + ) == (None, None) def test_ga5wb_shares_vr_n76_patch_series(): @@ -157,7 +157,10 @@ def test_ga5wb_shares_vr_n76_patch_series(): def test_bundle_md5_tail(): - bundle = FirmwareBundle(data=b"hello", update_info=oss_update_info(1)) + bundle = FirmwareBundle( + data=b"hello", + update_info=oss_update_info(1, "patch_base_to_vr_n76", "1"), + ) assert bundle.md5 == "5d41402abc4b2a76b9719d911017c592" assert bundle.md5_tail == bytes.fromhex("1017c592") assert bundle.size == 5 From bd211d46d59d1b7362dbf71f7f2baee4889f78b8 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:30:12 -0700 Subject: [PATCH 25/85] type the protobuf and device info conversions instead of using Any --- src/benlink/firmware/__init__.py | 9 +++++++-- src/benlink/firmware/__main__.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 312cb60..ccadea4 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -86,6 +86,9 @@ from ..common import ImmutableBaseModel +if t.TYPE_CHECKING: + from . import _benshikj_pb2 + OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" """@private""" @@ -146,7 +149,7 @@ class FirmwareInfo(ImmutableBaseModel): but the current one.""" @classmethod - def from_protocol(cls, info: t.Any) -> FirmwareInfo: + def from_protocol(cls, info: _benshikj_pb2.FirmwareInfo) -> FirmwareInfo: """@private (Protocol helper)""" return cls(version=info.version, url=info.url, md5=info.md5 or None) @@ -157,7 +160,9 @@ class UpdateInfo(ImmutableBaseModel): base: FirmwareInfo @classmethod - def from_protocol(cls, result: t.Any) -> UpdateInfo | None: + def from_protocol( + cls, result: _benshikj_pb2.CheckFirmwareUpdateResult + ) -> UpdateInfo | None: """@private (Protocol helper)""" if not result.firmware.url or not result.base.url: return None diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index c6e26d2..e5f7808 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -12,6 +12,9 @@ import sys import tempfile +if t.TYPE_CHECKING: + from ..command import CommandConnection, DeviceInfo + from . import ( BASE_IMAGES, PRODUCTS, @@ -100,7 +103,7 @@ def _confirm(question: str, default_yes: bool, assume_yes: bool) -> bool: ##################### # Radio -def _connection(args: argparse.Namespace): +def _connection(args: argparse.Namespace) -> CommandConnection: # Imported lazily: everything except the radio commands works without a # Bluetooth stack. from ..command import CommandConnection @@ -114,7 +117,7 @@ def _connection(args: argparse.Namespace): return CommandConnection.new_ble(args.uuid) -def _print_device_info(info: t.Any) -> None: +def _print_device_info(info: DeviceInfo) -> None: _out(f" vendor {info.vendor_id}, product {info.product_id}") _out(f" firmware v{info.firmware_version}" f", hardware version {info.hardware_version}") From 6b2763a747912574b451d30163177c3f7c665a02 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:54:30 -0700 Subject: [PATCH 26/85] silence unknown types from the untyped grpc stub --- src/benlink/firmware/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index ccadea4..d744fd3 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -226,7 +226,12 @@ async def check_update( async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: stub = _benshikj_pb2_grpc.DeviceManagementStub(channel) try: - result = await stub.CheckFirmwareUpdate(request, timeout=RPC_TIMEOUT) + # The generated grpc stub carries no type information. + result = t.cast( + "_benshikj_pb2.CheckFirmwareUpdateResult", + await stub.CheckFirmwareUpdate( # pyright: ignore + request, timeout=RPC_TIMEOUT), + ) except grpc.aio.AioRpcError as e: raise RuntimeError( f"update check failed: {e.code()} {e.details()}") From 2c6b2e5cc3d8d355c7a92adf56f13bd5653c16e3 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:54:30 -0700 Subject: [PATCH 27/85] escape underscore in flutter_benlink link --- src/benlink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index fc0a7fb..41f035d 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -86,7 +86,7 @@ async def main(): of so far: - [HTCommander](https://github.com/Ylianst/HTCommander) -- [flutter\_benlink](https://github.com/SarahRoseLives/flutter_benlink) +- [flutter\\_benlink](https://github.com/SarahRoseLives/flutter_benlink) If you've found benlink's documentation of the Benshi protocol helpful, or use benlink in your own project, please let me know so I can add it to this list. From 2bc663afb6f574afd0a6913073b863368afa789c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 21:57:29 -0700 Subject: [PATCH 28/85] call the update rpc directly instead of via a generated grpc stub --- Makefile | 3 +- src/benlink/firmware/__init__.py | 20 +++-- src/benlink/firmware/_benshikj_pb2_grpc.py | 97 ---------------------- 3 files changed, 13 insertions(+), 107 deletions(-) delete mode 100644 src/benlink/firmware/_benshikj_pb2_grpc.py diff --git a/Makefile b/Makefile index 8515553..64c1ba2 100644 --- a/Makefile +++ b/Makefile @@ -4,13 +4,12 @@ all: docs proto: cd src && python -m grpc_tools.protoc -I. \ - --python_out=. --pyi_out=. --grpc_python_out=. \ + --python_out=. --pyi_out=. \ benlink/firmware/_benshikj.proto docs: pdoc ./src/benlink \ '!benlink.firmware._benshikj_pb2' \ - '!benlink.firmware._benshikj_pb2_grpc' \ -o docs --logo /logo.svg cp ./assets/logo-transparent.svg docs/logo.svg diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index d744fd3..30142f7 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -95,6 +95,9 @@ RPC_HOST = "rpc.benshikj.com:800" """@private""" +RPC_METHOD = "/benshikj.DeviceManagement/CheckFirmwareUpdate" +"""@private""" + RPC_TIMEOUT = 10.0 """@private""" @@ -215,7 +218,7 @@ async def check_update( Requires `grpcio` and `protobuf`. """ grpc = _require("grpc", "grpcio") - from . import _benshikj_pb2, _benshikj_pb2_grpc + from . import _benshikj_pb2 request = _benshikj_pb2.CheckFirmwareUpdateRequest( product_id=product_id, @@ -224,14 +227,15 @@ async def check_update( credentials = grpc.ssl_channel_credentials() async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: - stub = _benshikj_pb2_grpc.DeviceManagementStub(channel) + call = channel.unary_unary( + RPC_METHOD, + request_serializer=( + _benshikj_pb2.CheckFirmwareUpdateRequest.SerializeToString), + response_deserializer=( + _benshikj_pb2.CheckFirmwareUpdateResult.FromString), + ) try: - # The generated grpc stub carries no type information. - result = t.cast( - "_benshikj_pb2.CheckFirmwareUpdateResult", - await stub.CheckFirmwareUpdate( # pyright: ignore - request, timeout=RPC_TIMEOUT), - ) + result = await call(request, timeout=RPC_TIMEOUT) except grpc.aio.AioRpcError as e: raise RuntimeError( f"update check failed: {e.code()} {e.details()}") diff --git a/src/benlink/firmware/_benshikj_pb2_grpc.py b/src/benlink/firmware/_benshikj_pb2_grpc.py deleted file mode 100644 index 9e28560..0000000 --- a/src/benlink/firmware/_benshikj_pb2_grpc.py +++ /dev/null @@ -1,97 +0,0 @@ -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc -import warnings - -from benlink.firmware import _benshikj_pb2 as benlink_dot_firmware_dot___benshikj__pb2 - -GRPC_GENERATED_VERSION = '1.82.1' -GRPC_VERSION = grpc.__version__ -_version_not_supported = False - -try: - from grpc._utilities import first_version_is_lower - _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) -except ImportError: - _version_not_supported = True - -if _version_not_supported: - raise RuntimeError( - f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in benlink/firmware/_benshikj_pb2_grpc.py depends on' - + f' grpcio>={GRPC_GENERATED_VERSION}.' - + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' - + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' - ) - - -class DeviceManagementStub: - """Missing associated documentation comment in .proto file.""" - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.CheckFirmwareUpdate = channel.unary_unary( - '/benshikj.DeviceManagement/CheckFirmwareUpdate', - request_serializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, - response_deserializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, - _registered_method=True) - - -class DeviceManagementServicer: - """Missing associated documentation comment in .proto file.""" - - def CheckFirmwareUpdate(self, request, context): - """Missing associated documentation comment in .proto file.""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_DeviceManagementServicer_to_server(servicer, server): - rpc_method_handlers = { - 'CheckFirmwareUpdate': grpc.unary_unary_rpc_method_handler( - servicer.CheckFirmwareUpdate, - request_deserializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.FromString, - response_serializer=benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'benshikj.DeviceManagement', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('benshikj.DeviceManagement', rpc_method_handlers) - - - # This class is part of an EXPERIMENTAL API. -class DeviceManagement: - """Missing associated documentation comment in .proto file.""" - - @staticmethod - def CheckFirmwareUpdate(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/benshikj.DeviceManagement/CheckFirmwareUpdate', - benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateRequest.SerializeToString, - benlink_dot_firmware_dot___benshikj__pb2.CheckFirmwareUpdateResult.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) From 8ac53ff13a25b168d783e1abf3c0293e31c79b41 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 22:04:15 -0700 Subject: [PATCH 29/85] hand-roll the update rpc codec instead of generating it --- Makefile | 11 +- pyproject.toml | 5 - src/benlink/firmware/__init__.py | 36 +++--- src/benlink/firmware/_benshikj.proto | 38 ------ src/benlink/firmware/_benshikj.py | 159 +++++++++++++++++++++++++ src/benlink/firmware/_benshikj_pb2.py | 42 ------- src/benlink/firmware/_benshikj_pb2.pyi | 42 ------- tests/test_firmware.py | 57 +++++++-- 8 files changed, 223 insertions(+), 167 deletions(-) delete mode 100644 src/benlink/firmware/_benshikj.proto create mode 100644 src/benlink/firmware/_benshikj.py delete mode 100644 src/benlink/firmware/_benshikj_pb2.py delete mode 100644 src/benlink/firmware/_benshikj_pb2.pyi diff --git a/Makefile b/Makefile index 64c1ba2..3af9619 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,9 @@ -.PHONY: docs proto +.PHONY: docs all: docs -proto: - cd src && python -m grpc_tools.protoc -I. \ - --python_out=. --pyi_out=. \ - benlink/firmware/_benshikj.proto - docs: - pdoc ./src/benlink \ - '!benlink.firmware._benshikj_pb2' \ - -o docs --logo /logo.svg + pdoc ./src/benlink -o docs --logo /logo.svg cp ./assets/logo-transparent.svg docs/logo.svg preview-docs: diff --git a/pyproject.toml b/pyproject.toml index 2c8cc6a..1dd2ca6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,11 +29,6 @@ requires-python = ">=3.10" firmware = [ "bsdiff4 >=1.2.0", "grpcio >=1.60.0", - "protobuf >=4.21.0", -] -# Only needed to regenerate the protobuf stubs; see `make proto`. -proto = [ - "grpcio-tools >=1.60.0", ] [project.urls] diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 30142f7..9ce98e6 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -85,9 +85,7 @@ import zipfile from ..common import ImmutableBaseModel - -if t.TYPE_CHECKING: - from . import _benshikj_pb2 +from . import _benshikj OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" """@private""" @@ -95,9 +93,6 @@ RPC_HOST = "rpc.benshikj.com:800" """@private""" -RPC_METHOD = "/benshikj.DeviceManagement/CheckFirmwareUpdate" -"""@private""" - RPC_TIMEOUT = 10.0 """@private""" @@ -128,6 +123,11 @@ the current release. """ +def _identity(data: bytes) -> bytes: + """@private (the RPC messages are encoded by hand; see `_benshikj`)""" + return data + + def _require(module: str, package: str): try: return __import__(module) @@ -152,7 +152,7 @@ class FirmwareInfo(ImmutableBaseModel): but the current one.""" @classmethod - def from_protocol(cls, info: _benshikj_pb2.FirmwareInfo) -> FirmwareInfo: + def from_protocol(cls, info: _benshikj.FirmwareInfo) -> FirmwareInfo: """@private (Protocol helper)""" return cls(version=info.version, url=info.url, md5=info.md5 or None) @@ -164,7 +164,7 @@ class UpdateInfo(ImmutableBaseModel): @classmethod def from_protocol( - cls, result: _benshikj_pb2.CheckFirmwareUpdateResult + cls, result: _benshikj.CheckFirmwareUpdateResult ) -> UpdateInfo | None: """@private (Protocol helper)""" if not result.firmware.url or not result.base.url: @@ -215,32 +215,26 @@ async def check_update( returns the latest release regardless of its value, so it has no effect in practice. Returns `None` if the server reports no update. - Requires `grpcio` and `protobuf`. + Requires `grpcio`. """ grpc = _require("grpc", "grpcio") - from . import _benshikj_pb2 - request = _benshikj_pb2.CheckFirmwareUpdateRequest( - product_id=product_id, - firmware_version=firmware_version, - ) + request = _benshikj.encode_check_request(product_id, firmware_version) credentials = grpc.ssl_channel_credentials() async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: call = channel.unary_unary( - RPC_METHOD, - request_serializer=( - _benshikj_pb2.CheckFirmwareUpdateRequest.SerializeToString), - response_deserializer=( - _benshikj_pb2.CheckFirmwareUpdateResult.FromString), + _benshikj.METHOD, + request_serializer=_identity, + response_deserializer=_identity, ) try: - result = await call(request, timeout=RPC_TIMEOUT) + response: bytes = await call(request, timeout=RPC_TIMEOUT) except grpc.aio.AioRpcError as e: raise RuntimeError( f"update check failed: {e.code()} {e.details()}") - return UpdateInfo.from_protocol(result) + return UpdateInfo.from_protocol(_benshikj.decode_check_result(response)) def oss_patch_url(version: int, patch_name: str) -> str: diff --git a/src/benlink/firmware/_benshikj.proto b/src/benlink/firmware/_benshikj.proto deleted file mode 100644 index 9ca5067..0000000 --- a/src/benlink/firmware/_benshikj.proto +++ /dev/null @@ -1,38 +0,0 @@ -// Vendor update server API, reconstructed from observed traffic. -// -// Field numbers are the contract; names here are our own. Only the firmware -// update check is modelled — the real service has other methods. -// -// Regenerate with `make proto`. - -syntax = "proto3"; - -package benshikj; - -message CheckFirmwareUpdateRequest { - int32 product_id = 1; - int32 firmware_version = 2; - bool beta = 3; - int64 user_id = 4; - int32 invite_code = 5; -} - -message FirmwareInfo { - int32 version = 1; - string url = 2; - // Not the md5 of the file at `url`: for the patch this is the md5 of the - // assembled firmware, and for the base it is the md5 of the .bin inside the zip. - string md5 = 3; - string release_notes = 4; - string release_date = 5; -} - -message CheckFirmwareUpdateResult { - FirmwareInfo firmware = 1; - FirmwareInfo base = 2; -} - -service DeviceManagement { - rpc CheckFirmwareUpdate(CheckFirmwareUpdateRequest) - returns (CheckFirmwareUpdateResult); -} diff --git a/src/benlink/firmware/_benshikj.py b/src/benlink/firmware/_benshikj.py new file mode 100644 index 0000000..7221a1a --- /dev/null +++ b/src/benlink/firmware/_benshikj.py @@ -0,0 +1,159 @@ +"""Wire format for the vendor's firmware update RPC. + +The update server speaks gRPC, but only one method matters and its messages are +small, so they are encoded by hand rather than through protoc. That keeps the +schema readable in source, avoids a protobuf runtime dependency, and avoids +checked-in generated code that stops working on a future protobuf major release. + +The `DeviceManagement` service has three methods (`CheckFirmwareUpdate`, +`GetRegTimes`, `SetRegTimes`); only the firmware check is modelled here. Field +numbers are the contract, and the names follow the vendor's. + + syntax = "proto3"; + + package benshikj; + + message CheckFirmwareUpdateRequest { + int32 product_id = 1; + int32 firmware_version = 2; + bool beta = 3; + int64 user_id = 4; + int32 invite_code = 5; + } + + message FirmwareInfo { + int32 version = 1; + string url = 2; + string md5 = 3; + string release_notes = 4; + string release_date = 5; + } + + message CheckFirmwareUpdateResult { + FirmwareInfo firmware = 1; + FirmwareInfo base = 2; + } + + service DeviceManagement { + rpc CheckFirmwareUpdate(CheckFirmwareUpdateRequest) + returns (CheckFirmwareUpdateResult); + } + +Note that `md5` does not describe the file at `url`: for the patch it is the md5 +of the *assembled* firmware, and for the base it is the md5 of the `.bin` inside +the zip. +""" + +from __future__ import annotations +import typing as t + +METHOD = "/benshikj.DeviceManagement/CheckFirmwareUpdate" + +WIRE_VARINT = 0 +"""@private""" + +WIRE_BYTES = 2 +"""@private""" + + +class FirmwareInfo(t.NamedTuple): + """A decoded `benshikj.FirmwareInfo`.""" + version: int = 0 + url: str = "" + md5: str = "" + + +class CheckFirmwareUpdateResult(t.NamedTuple): + """A decoded `benshikj.CheckFirmwareUpdateResult`.""" + firmware: FirmwareInfo = FirmwareInfo() + base: FirmwareInfo = FirmwareInfo() + + +def _encode_varint(value: int) -> bytes: + out = bytearray() + while value > 0x7F: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value) + return bytes(out) + + +def _encode_varint_field(field: int, value: int) -> bytes: + return _encode_varint(field << 3 | WIRE_VARINT) + _encode_varint(value) + + +def _read_varint(data: bytes, pos: int) -> t.Tuple[int, int]: + value = shift = 0 + while pos < len(data): + byte = data[pos] + pos += 1 + value |= (byte & 0x7F) << shift + if not byte & 0x80: + break + shift += 7 + return value, pos + + +def _walk(data: bytes) -> t.Iterator[t.Tuple[int, int, int, bytes]]: + """Yield `(field_number, wire_type, varint_value, delimited_value)`. + + Only one of the two values is meaningful, according to the wire type. + Unrecognised wire types end the walk, since their length is unknown. + """ + pos = 0 + while pos < len(data): + tag, pos = _read_varint(data, pos) + field, wire = tag >> 3, tag & 0x7 + + if wire == WIRE_VARINT: + value, pos = _read_varint(data, pos) + yield field, wire, value, b"" + elif wire == WIRE_BYTES: + length, pos = _read_varint(data, pos) + yield field, wire, 0, data[pos:pos + length] + pos += length + elif wire == 5: + pos += 4 + elif wire == 1: + pos += 8 + else: + return + + +def encode_check_request(product_id: int, firmware_version: int = 0) -> bytes: + """Encode a `CheckFirmwareUpdateRequest`. + + proto3 omits zero-valued fields, so a request carrying only a product id asks + for the latest release. + """ + out = b"" + if product_id: + out += _encode_varint_field(1, product_id) + if firmware_version: + out += _encode_varint_field(2, firmware_version) + return out + + +def _decode_firmware_info(data: bytes) -> FirmwareInfo: + version, url, md5 = 0, "", "" + for field, wire, varint, delimited in _walk(data): + if field == 1 and wire == WIRE_VARINT: + version = varint + elif field == 2 and wire == WIRE_BYTES: + url = delimited.decode("utf-8", "replace") + elif field == 3 and wire == WIRE_BYTES: + md5 = delimited.decode("utf-8", "replace") + return FirmwareInfo(version=version, url=url, md5=md5) + + +def decode_check_result(data: bytes) -> CheckFirmwareUpdateResult: + """Decode a `CheckFirmwareUpdateResult`. Absent fields decode as empty.""" + firmware = base = FirmwareInfo() + for field, wire, _, delimited in _walk(data): + if wire != WIRE_BYTES: + continue + if field == 1: + firmware = _decode_firmware_info(delimited) + elif field == 2: + base = _decode_firmware_info(delimited) + return CheckFirmwareUpdateResult(firmware=firmware, base=base) diff --git a/src/benlink/firmware/_benshikj_pb2.py b/src/benlink/firmware/_benshikj_pb2.py deleted file mode 100644 index a140aac..0000000 --- a/src/benlink/firmware/_benshikj_pb2.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: benlink/firmware/_benshikj.proto -# Protobuf Python Version: 7.35.0 -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 7, - 35, - 0, - '', - 'benlink/firmware/_benshikj.proto' -) -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n benlink/firmware/_benshikj.proto\x12\x08\x62\x65nshikj\"~\n\x1a\x43heckFirmwareUpdateRequest\x12\x12\n\nproduct_id\x18\x01 \x01(\x05\x12\x18\n\x10\x66irmware_version\x18\x02 \x01(\x05\x12\x0c\n\x04\x62\x65ta\x18\x03 \x01(\x08\x12\x0f\n\x07user_id\x18\x04 \x01(\x03\x12\x13\n\x0binvite_code\x18\x05 \x01(\x05\"f\n\x0c\x46irmwareInfo\x12\x0f\n\x07version\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0b\n\x03md5\x18\x03 \x01(\t\x12\x15\n\rrelease_notes\x18\x04 \x01(\t\x12\x14\n\x0crelease_date\x18\x05 \x01(\t\"k\n\x19\x43heckFirmwareUpdateResult\x12(\n\x08\x66irmware\x18\x01 \x01(\x0b\x32\x16.benshikj.FirmwareInfo\x12$\n\x04\x62\x61se\x18\x02 \x01(\x0b\x32\x16.benshikj.FirmwareInfo2t\n\x10\x44\x65viceManagement\x12`\n\x13\x43heckFirmwareUpdate\x12$.benshikj.CheckFirmwareUpdateRequest\x1a#.benshikj.CheckFirmwareUpdateResultb\x06proto3') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'benlink.firmware._benshikj_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None - _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_start=46 - _globals['_CHECKFIRMWAREUPDATEREQUEST']._serialized_end=172 - _globals['_FIRMWAREINFO']._serialized_start=174 - _globals['_FIRMWAREINFO']._serialized_end=276 - _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_start=278 - _globals['_CHECKFIRMWAREUPDATERESULT']._serialized_end=385 - _globals['_DEVICEMANAGEMENT']._serialized_start=387 - _globals['_DEVICEMANAGEMENT']._serialized_end=503 -# @@protoc_insertion_point(module_scope) diff --git a/src/benlink/firmware/_benshikj_pb2.pyi b/src/benlink/firmware/_benshikj_pb2.pyi deleted file mode 100644 index 22ccaf2..0000000 --- a/src/benlink/firmware/_benshikj_pb2.pyi +++ /dev/null @@ -1,42 +0,0 @@ -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from collections.abc import Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class CheckFirmwareUpdateRequest(_message.Message): - __slots__ = ("product_id", "firmware_version", "beta", "user_id", "invite_code") - PRODUCT_ID_FIELD_NUMBER: _ClassVar[int] - FIRMWARE_VERSION_FIELD_NUMBER: _ClassVar[int] - BETA_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] - INVITE_CODE_FIELD_NUMBER: _ClassVar[int] - product_id: int - firmware_version: int - beta: bool - user_id: int - invite_code: int - def __init__(self, product_id: _Optional[int] = ..., firmware_version: _Optional[int] = ..., beta: _Optional[bool] = ..., user_id: _Optional[int] = ..., invite_code: _Optional[int] = ...) -> None: ... - -class FirmwareInfo(_message.Message): - __slots__ = ("version", "url", "md5", "release_notes", "release_date") - VERSION_FIELD_NUMBER: _ClassVar[int] - URL_FIELD_NUMBER: _ClassVar[int] - MD5_FIELD_NUMBER: _ClassVar[int] - RELEASE_NOTES_FIELD_NUMBER: _ClassVar[int] - RELEASE_DATE_FIELD_NUMBER: _ClassVar[int] - version: int - url: str - md5: str - release_notes: str - release_date: str - def __init__(self, version: _Optional[int] = ..., url: _Optional[str] = ..., md5: _Optional[str] = ..., release_notes: _Optional[str] = ..., release_date: _Optional[str] = ...) -> None: ... - -class CheckFirmwareUpdateResult(_message.Message): - __slots__ = ("firmware", "base") - FIRMWARE_FIELD_NUMBER: _ClassVar[int] - BASE_FIELD_NUMBER: _ClassVar[int] - firmware: FirmwareInfo - base: FirmwareInfo - def __init__(self, firmware: _Optional[_Union[FirmwareInfo, _Mapping]] = ..., base: _Optional[_Union[FirmwareInfo, _Mapping]] = ...) -> None: ... diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 0bbe4f4..405dc5e 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -3,6 +3,7 @@ import pytest +from benlink.firmware import _benshikj from benlink.firmware import ( PRODUCTS, FirmwareBundle, @@ -16,25 +17,60 @@ ) bsdiff4 = pytest.importorskip("bsdiff4") -pytest.importorskip("google.protobuf") -from benlink.firmware import _benshikj_pb2 # noqa: E402 +def _varint(value: int) -> bytes: + out = bytearray() + while value > 0x7F: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value) + return bytes(out) -def test_request_field_numbers(): - # The field numbers are the wire contract; the names are ours. - request = _benshikj_pb2.CheckFirmwareUpdateRequest(product_id=259) - assert request.SerializeToString() == b"\x08\x83\x02" + +def _delimited(field: int, payload: bytes) -> bytes: + return _varint(field << 3 | 2) + _varint(len(payload)) + payload + + +def _firmware_info_bytes(info: _benshikj.FirmwareInfo) -> bytes: + return ( + _varint(1 << 3) + _varint(info.version) + + _delimited(2, info.url.encode()) + + _delimited(3, info.md5.encode()) + ) + + +def test_encode_check_request(): + # Field numbers are the wire contract: product_id is field 1, varint. + assert _benshikj.encode_check_request(259) == b"\x08\x83\x02" + assert _benshikj.encode_check_request(259, 147) == b"\x08\x83\x02\x10\x93\x01" + # proto3 omits zero-valued fields + assert _benshikj.encode_check_request(0) == b"" + + +def test_encode_decode_roundtrip(): + info = _benshikj.FirmwareInfo(147, "https://example.invalid/p.bin", "abc") + encoded = _delimited(1, _firmware_info_bytes(info)) + decoded = _benshikj.decode_check_result(encoded) + assert decoded.firmware == info + assert decoded.base == _benshikj.FirmwareInfo() + + +def test_decode_stops_on_unknown_wire_type(): + # tag with wire type 7 (invalid); the walk must not loop or raise + assert _benshikj.decode_check_result(b"\x0f\x01\x02") == ( + _benshikj.CheckFirmwareUpdateResult() + ) def test_update_info_from_protocol(): - result = _benshikj_pb2.CheckFirmwareUpdateResult( - firmware=_benshikj_pb2.FirmwareInfo( + result = _benshikj.CheckFirmwareUpdateResult( + firmware=_benshikj.FirmwareInfo( version=147, url="https://example.invalid/patch.bin", md5="0c0d095da50bebe664822adcb244834a", ), - base=_benshikj_pb2.FirmwareInfo( + base=_benshikj.FirmwareInfo( url="https://example.invalid/base.zip", md5="74b6d097d8d2d9d2d9fac88133198a08", ), @@ -55,7 +91,8 @@ def test_update_info_from_protocol(): def test_update_info_from_protocol_empty_means_no_update(): - assert UpdateInfo.from_protocol(_benshikj_pb2.CheckFirmwareUpdateResult()) is None + empty = _benshikj.CheckFirmwareUpdateResult() + assert UpdateInfo.from_protocol(empty) is None def test_oss_urls(): From f1461e070129b777d248c52b2a222cb1715eb3da Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 11:07:43 -0700 Subject: [PATCH 30/85] split firmware into fetch and flash modules --- src/benlink/firmware/__init__.py | 342 ++++--------------------------- src/benlink/firmware/_fetch.py | 310 ++++++++++++++++++++++++++++ src/benlink/firmware/_flash.py | 60 ++++++ 3 files changed, 407 insertions(+), 305 deletions(-) create mode 100644 src/benlink/firmware/_fetch.py create mode 100644 src/benlink/firmware/_flash.py diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 9ce98e6..90a29a8 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -76,308 +76,40 @@ and available versions compare directly. """ -from __future__ import annotations -import typing as t -import asyncio -import hashlib -import io -import urllib.request -import zipfile - -from ..common import ImmutableBaseModel -from . import _benshikj - -OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" -"""@private""" - -RPC_HOST = "rpc.benshikj.com:800" -"""@private""" - -RPC_TIMEOUT = 10.0 -"""@private""" - -PRODUCTS: t.Dict[str, t.Tuple[int, str]] = { - "VR_N76": (259, "patch_base_to_vr_n76"), - "GA_5WB": (259, "patch_base_to_vr_n76"), - "UV_PRO": (260, "patch_base_to_vr_n76_m"), - "VR_N75": (261, "patch_base_to_vr_n75_h2"), -} -"""Known radios, as `name: (product_id, patch_name)`. - -Every patch name here was returned by the update server for the corresponding product -id. Note that 259 covers both the VR-N76 and the GA-5WB, which share a patch series, -confirmed by a GA-5WB flash capture whose `md5sum_tail` matches -`patch_base_to_vr_n76.v120` assembled against the shared base. -""" - -BASE_IMAGES: t.Dict[str, str] = { - "original": "upgrade_base.bin.zip", - "1": "upgrade_base_v1.bin.zip", -} -"""The base images a patch can be built against, as `name: filename`. - -A patch carries no checksum of its source, so pairing it with the wrong base produces -a corrupt image with no error (see `assemble`). Known pairings, from flash captures and -from the update server: patch v120, v121 and v128 use `original`; v147 uses `1`. Where -the changeover happened is not known, because the server only publishes metadata for -the current release. -""" - -def _identity(data: bytes) -> bytes: - """@private (the RPC messages are encoded by hand; see `_benshikj`)""" - return data - - -def _require(module: str, package: str): - try: - return __import__(module) - except ImportError: - raise ImportError( - f"{package} is required for this operation. " - f"Install with: pip install benlink[firmware]" - ) - - -##################### -# Data - -class FirmwareInfo(ImmutableBaseModel): - """One downloadable artifact (either the patch or the base image).""" - version: int - url: str - md5: str | None - """md5 of the *assembled* image for a patch, or of the *extracted* base image. - - `None` when no reference md5 is available, which is the case for every release - but the current one.""" - - @classmethod - def from_protocol(cls, info: _benshikj.FirmwareInfo) -> FirmwareInfo: - """@private (Protocol helper)""" - return cls(version=info.version, url=info.url, md5=info.md5 or None) - - -class UpdateInfo(ImmutableBaseModel): - """The patch and base image that together make up a firmware release.""" - firmware: FirmwareInfo - base: FirmwareInfo - - @classmethod - def from_protocol( - cls, result: _benshikj.CheckFirmwareUpdateResult - ) -> UpdateInfo | None: - """@private (Protocol helper)""" - if not result.firmware.url or not result.base.url: - return None - return cls( - firmware=FirmwareInfo.from_protocol(result.firmware), - base=FirmwareInfo.from_protocol(result.base), - ) - - -class FirmwareBundle(ImmutableBaseModel): - """An assembled, ready-to-flash firmware image.""" - data: bytes - update_info: UpdateInfo - - @property - def md5(self) -> str: - return hashlib.md5(self.data).hexdigest() - - @property - def md5_tail(self) -> bytes: - """Last 4 bytes of the md5 digest, as sent in `UPDATE_SYNC_REQ`.""" - return bytes.fromhex(self.md5)[-4:] - - @property - def size(self) -> int: - return len(self.data) - - def save(self, path: str) -> None: - with open(path, "wb") as f: - f.write(self.data) - - -ProgressCallback = t.Callable[[str, int, int], None] -"""`progress(label, bytes_done, bytes_total)`. `bytes_total` is 0 if unknown.""" - - -##################### -# Finding an update - -async def check_update( - product_id: int, - firmware_version: int = 0, -) -> UpdateInfo | None: - """Ask the update server for the latest release for `product_id`. - - `firmware_version` is the currently installed internal version. The server - returns the latest release regardless of its value, so it has no effect in - practice. Returns `None` if the server reports no update. - - Requires `grpcio`. - """ - grpc = _require("grpc", "grpcio") - - request = _benshikj.encode_check_request(product_id, firmware_version) - - credentials = grpc.ssl_channel_credentials() - async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: - call = channel.unary_unary( - _benshikj.METHOD, - request_serializer=_identity, - response_deserializer=_identity, - ) - try: - response: bytes = await call(request, timeout=RPC_TIMEOUT) - except grpc.aio.AioRpcError as e: - raise RuntimeError( - f"update check failed: {e.code()} {e.details()}") - - return UpdateInfo.from_protocol(_benshikj.decode_check_result(response)) - - -def oss_patch_url(version: int, patch_name: str) -> str: - """URL of a patch in the object store.""" - return f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin" - - -def oss_base_url(base_image: str) -> str: - """URL of a base image in the object store. `base_image` is a key of - `BASE_IMAGES`.""" - if base_image not in BASE_IMAGES: - raise RuntimeError( - f"unknown base image {base_image!r}, expected one of " - f"{', '.join(BASE_IMAGES)}" - ) - return f"{OSS_BASE_URL}/{BASE_IMAGES[base_image]}" - - -def oss_update_info( - version: int, - patch_name: str, - base_image: str, -) -> UpdateInfo: - """Construct object-store URLs for a known version, without contacting the - update server. - - No md5s are available this way, so the result cannot be verified. Since a patch - only applies to the base it shipped with, picking the wrong `base_image` yields a - corrupt image silently. See `BASE_IMAGES`. - """ - return UpdateInfo( - firmware=FirmwareInfo( - version=version, - url=oss_patch_url(version, patch_name), - md5=None, - ), - base=FirmwareInfo(version=0, url=oss_base_url(base_image), md5=None), - ) - - -##################### -# Downloading and assembling - -async def download( - url: str, - label: str = "download", - progress: ProgressCallback | None = None, -) -> bytes: - """Download a single artifact.""" - return await asyncio.to_thread(_download, url, label, progress) - - -def _download(url: str, label: str, progress: ProgressCallback | None) -> bytes: - with urllib.request.urlopen(url) as response: - total = int(response.headers.get("Content-Length", 0)) - chunks: t.List[bytes] = [] - received = 0 - while chunk := response.read(65536): - chunks.append(chunk) - received += len(chunk) - if progress: - progress(label, received, total) - return b"".join(chunks) - - -def _verify(data: bytes, expected_md5: str | None, label: str) -> None: - if not expected_md5: - return - actual = hashlib.md5(data).hexdigest() - if actual != expected_md5: - raise RuntimeError( - f"{label} md5 mismatch: expected {expected_md5}, got {actual}" - ) - - -def extract_base(base: bytes) -> bytes: - """Return the base image, unwrapping the zip it ships in if needed.""" - if base[:2] != b"PK": - return base - - with zipfile.ZipFile(io.BytesIO(base)) as zf: - names = [n for n in zf.namelist() if n.endswith(".bin")] - if not names: - raise RuntimeError("no .bin found in base zip") - return zf.read(names[0]) - - -def assemble(base: bytes, patch: bytes) -> bytes: - """Apply a BSDIFF40 patch to a base image. - - `base` may be either the raw base image or the zip it ships in. - - A patch carries no checksum of the base it was built against, so applying it to - the wrong base succeeds and silently yields a corrupt image. Patches are only - valid against the base image released alongside them. Compare the result against - `UpdateInfo.firmware.md5` whenever it is known. - """ - bsdiff4 = _require("bsdiff4", "bsdiff4") - - if patch[:8] != b"BSDIFF40": - raise RuntimeError( - f"unexpected patch magic {patch[:8]!r}, expected b'BSDIFF40'" - ) - - return bsdiff4.patch(extract_base(base), patch) - - -async def download_firmware( - update_info: UpdateInfo, - progress: ProgressCallback | None = None, -) -> FirmwareBundle: - """Download the patch and base image named by `update_info` and assemble them. - - Both are always fetched fresh. Base images are revised over time and a patch - only applies to the one released with it, so reusing a local copy risks pairing - a patch with a base it was never built against. - - Requires `bsdiff4`. - """ - patch, base = await asyncio.gather( - asyncio.to_thread(_download, update_info.firmware.url, - "patch", progress), - asyncio.to_thread(_download, update_info.base.url, "base", progress), - ) - - # The server's md5s describe the extracted base and the assembled firmware, - # neither the base zip nor the patch file as downloaded. - base = extract_base(base) - _verify(base, update_info.base.md5, "base image") - - data = await asyncio.to_thread(assemble, base, patch) - _verify(data, update_info.firmware.md5, "assembled firmware") - - return FirmwareBundle(data=data, update_info=update_info) - - -async def fetch_firmware( - product_id: int, - firmware_version: int = 0, - progress: ProgressCallback | None = None, -) -> FirmwareBundle | None: - """Check for an update and download it if one is available.""" - update_info = await check_update(product_id, firmware_version) - if update_info is None: - return None - return await download_firmware(update_info, progress) +from ._fetch import ( + BASE_IMAGES, + PRODUCTS, + FirmwareBundle, + FirmwareInfo, + ProgressCallback, + UpdateInfo, + assemble, + check_update, + download, + download_firmware, + extract_base, + fetch_firmware, + oss_base_url, + oss_patch_url, + oss_update_info, +) +from ._flash import flash + +__all__ = [ + "BASE_IMAGES", + "PRODUCTS", + "FirmwareBundle", + "FirmwareInfo", + "ProgressCallback", + "UpdateInfo", + "assemble", + "check_update", + "download", + "download_firmware", + "extract_base", + "fetch_firmware", + "flash", + "oss_base_url", + "oss_patch_url", + "oss_update_info", +] diff --git a/src/benlink/firmware/_fetch.py b/src/benlink/firmware/_fetch.py new file mode 100644 index 0000000..73831a0 --- /dev/null +++ b/src/benlink/firmware/_fetch.py @@ -0,0 +1,310 @@ +"""Finding, downloading and assembling firmware images. + +See `benlink.firmware` for an overview and for the command line interface. +""" + +from __future__ import annotations +import typing as t +import asyncio +import hashlib +import io +import urllib.request +import zipfile + +from ..common import ImmutableBaseModel +from . import _benshikj + +OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" +"""@private""" + +RPC_HOST = "rpc.benshikj.com:800" +"""@private""" + +RPC_TIMEOUT = 10.0 +"""@private""" + +PRODUCTS: t.Dict[str, t.Tuple[int, str]] = { + "VR_N76": (259, "patch_base_to_vr_n76"), + "GA_5WB": (259, "patch_base_to_vr_n76"), + "UV_PRO": (260, "patch_base_to_vr_n76_m"), + "VR_N75": (261, "patch_base_to_vr_n75_h2"), +} +"""Known radios, as `name: (product_id, patch_name)`. + +Every patch name here was returned by the update server for the corresponding product +id. Note that 259 covers both the VR-N76 and the GA-5WB, which share a patch series, +confirmed by a GA-5WB flash capture whose `md5sum_tail` matches +`patch_base_to_vr_n76.v120` assembled against the shared base. +""" + +BASE_IMAGES: t.Dict[str, str] = { + "original": "upgrade_base.bin.zip", + "1": "upgrade_base_v1.bin.zip", +} +"""The base images a patch can be built against, as `name: filename`. + +A patch carries no checksum of its source, so pairing it with the wrong base produces +a corrupt image with no error (see `assemble`). Known pairings, from flash captures and +from the update server: patch v120, v121 and v128 use `original`; v147 uses `1`. Where +the changeover happened is not known, because the server only publishes metadata for +the current release. +""" + +def _identity(data: bytes) -> bytes: + """@private (the RPC messages are encoded by hand; see `_benshikj`)""" + return data + + +def _require(module: str, package: str): + try: + return __import__(module) + except ImportError: + raise ImportError( + f"{package} is required for this operation. " + f"Install with: pip install benlink[firmware]" + ) + + +##################### +# Data + +class FirmwareInfo(ImmutableBaseModel): + """One downloadable artifact (either the patch or the base image).""" + version: int + url: str + md5: str | None + """md5 of the *assembled* image for a patch, or of the *extracted* base image. + + `None` when no reference md5 is available, which is the case for every release + but the current one.""" + + @classmethod + def from_protocol(cls, info: _benshikj.FirmwareInfo) -> FirmwareInfo: + """@private (Protocol helper)""" + return cls(version=info.version, url=info.url, md5=info.md5 or None) + + +class UpdateInfo(ImmutableBaseModel): + """The patch and base image that together make up a firmware release.""" + firmware: FirmwareInfo + base: FirmwareInfo + + @classmethod + def from_protocol( + cls, result: _benshikj.CheckFirmwareUpdateResult + ) -> UpdateInfo | None: + """@private (Protocol helper)""" + if not result.firmware.url or not result.base.url: + return None + return cls( + firmware=FirmwareInfo.from_protocol(result.firmware), + base=FirmwareInfo.from_protocol(result.base), + ) + + +class FirmwareBundle(ImmutableBaseModel): + """An assembled, ready-to-flash firmware image.""" + data: bytes + update_info: UpdateInfo + + @property + def md5(self) -> str: + return hashlib.md5(self.data).hexdigest() + + @property + def md5_tail(self) -> bytes: + """Last 4 bytes of the md5 digest, as sent in `UPDATE_SYNC_REQ`.""" + return bytes.fromhex(self.md5)[-4:] + + @property + def size(self) -> int: + return len(self.data) + + def save(self, path: str) -> None: + with open(path, "wb") as f: + f.write(self.data) + + +ProgressCallback = t.Callable[[str, int, int], None] +"""`progress(label, bytes_done, bytes_total)`. `bytes_total` is 0 if unknown.""" + + +##################### +# Finding an update + +async def check_update( + product_id: int, + firmware_version: int = 0, +) -> UpdateInfo | None: + """Ask the update server for the latest release for `product_id`. + + `firmware_version` is the currently installed internal version. The server + returns the latest release regardless of its value, so it has no effect in + practice. Returns `None` if the server reports no update. + + Requires `grpcio`. + """ + grpc = _require("grpc", "grpcio") + + request = _benshikj.encode_check_request(product_id, firmware_version) + + credentials = grpc.ssl_channel_credentials() + async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: + call = channel.unary_unary( + _benshikj.METHOD, + request_serializer=_identity, + response_deserializer=_identity, + ) + try: + response: bytes = await call(request, timeout=RPC_TIMEOUT) + except grpc.aio.AioRpcError as e: + raise RuntimeError( + f"update check failed: {e.code()} {e.details()}") + + return UpdateInfo.from_protocol(_benshikj.decode_check_result(response)) + + +def oss_patch_url(version: int, patch_name: str) -> str: + """URL of a patch in the object store.""" + return f"{OSS_BASE_URL}/firmware/v{version}/{patch_name}.bin" + + +def oss_base_url(base_image: str) -> str: + """URL of a base image in the object store. `base_image` is a key of + `BASE_IMAGES`.""" + if base_image not in BASE_IMAGES: + raise RuntimeError( + f"unknown base image {base_image!r}, expected one of " + f"{', '.join(BASE_IMAGES)}" + ) + return f"{OSS_BASE_URL}/{BASE_IMAGES[base_image]}" + + +def oss_update_info( + version: int, + patch_name: str, + base_image: str, +) -> UpdateInfo: + """Construct object-store URLs for a known version, without contacting the + update server. + + No md5s are available this way, so the result cannot be verified. Since a patch + only applies to the base it shipped with, picking the wrong `base_image` yields a + corrupt image silently. See `BASE_IMAGES`. + """ + return UpdateInfo( + firmware=FirmwareInfo( + version=version, + url=oss_patch_url(version, patch_name), + md5=None, + ), + base=FirmwareInfo(version=0, url=oss_base_url(base_image), md5=None), + ) + + +##################### +# Downloading and assembling + +async def download( + url: str, + label: str = "download", + progress: ProgressCallback | None = None, +) -> bytes: + """Download a single artifact.""" + return await asyncio.to_thread(_download, url, label, progress) + + +def _download(url: str, label: str, progress: ProgressCallback | None) -> bytes: + with urllib.request.urlopen(url) as response: + total = int(response.headers.get("Content-Length", 0)) + chunks: t.List[bytes] = [] + received = 0 + while chunk := response.read(65536): + chunks.append(chunk) + received += len(chunk) + if progress: + progress(label, received, total) + return b"".join(chunks) + + +def _verify(data: bytes, expected_md5: str | None, label: str) -> None: + if not expected_md5: + return + actual = hashlib.md5(data).hexdigest() + if actual != expected_md5: + raise RuntimeError( + f"{label} md5 mismatch: expected {expected_md5}, got {actual}" + ) + + +def extract_base(base: bytes) -> bytes: + """Return the base image, unwrapping the zip it ships in if needed.""" + if base[:2] != b"PK": + return base + + with zipfile.ZipFile(io.BytesIO(base)) as zf: + names = [n for n in zf.namelist() if n.endswith(".bin")] + if not names: + raise RuntimeError("no .bin found in base zip") + return zf.read(names[0]) + + +def assemble(base: bytes, patch: bytes) -> bytes: + """Apply a BSDIFF40 patch to a base image. + + `base` may be either the raw base image or the zip it ships in. + + A patch carries no checksum of the base it was built against, so applying it to + the wrong base succeeds and silently yields a corrupt image. Patches are only + valid against the base image released alongside them. Compare the result against + `UpdateInfo.firmware.md5` whenever it is known. + """ + bsdiff4 = _require("bsdiff4", "bsdiff4") + + if patch[:8] != b"BSDIFF40": + raise RuntimeError( + f"unexpected patch magic {patch[:8]!r}, expected b'BSDIFF40'" + ) + + return bsdiff4.patch(extract_base(base), patch) + + +async def download_firmware( + update_info: UpdateInfo, + progress: ProgressCallback | None = None, +) -> FirmwareBundle: + """Download the patch and base image named by `update_info` and assemble them. + + Both are always fetched fresh. Base images are revised over time and a patch + only applies to the one released with it, so reusing a local copy risks pairing + a patch with a base it was never built against. + + Requires `bsdiff4`. + """ + patch, base = await asyncio.gather( + asyncio.to_thread(_download, update_info.firmware.url, + "patch", progress), + asyncio.to_thread(_download, update_info.base.url, "base", progress), + ) + + # The server's md5s describe the extracted base and the assembled firmware, + # neither the base zip nor the patch file as downloaded. + base = extract_base(base) + _verify(base, update_info.base.md5, "base image") + + data = await asyncio.to_thread(assemble, base, patch) + _verify(data, update_info.firmware.md5, "assembled firmware") + + return FirmwareBundle(data=data, update_info=update_info) + + +async def fetch_firmware( + product_id: int, + firmware_version: int = 0, + progress: ProgressCallback | None = None, +) -> FirmwareBundle | None: + """Check for an update and download it if one is available.""" + update_info = await check_update(product_id, firmware_version) + if update_info is None: + return None + return await download_firmware(update_info, progress) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py new file mode 100644 index 0000000..1687014 --- /dev/null +++ b/src/benlink/firmware/_flash.py @@ -0,0 +1,60 @@ +"""Delivering an assembled firmware image to a radio. + +**Not implemented yet** ([issue #10](https://github.com/khusmann/benlink/issues/10)). + +The message types are in `benlink.protocol.command.vm`; what remains is the state +machine that drives them. The transfer runs over the same command connection as +everything else, in two phases separated by a reboot: + + VM_CONNECT + UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM + UPDATE_START_REQ -> UPDATE_START_CFM + UPDATE_START_DATA_REQ + UPDATE_DATA <- UPDATE_DATA_BYTES_REQ (repeats) + UPDATE_IS_VALIDATION_DONE_REQ + -> UPDATE_TRANSFER_COMPLETE_IND + UPDATE_TRANSFER_COMPLETE_RES + + [radio reboots, connection drops, reconnect] + + VM_CONNECT + UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM + UPDATE_START_REQ -> UPDATE_START_CFM + UPDATE_IN_PROGRESS_RES -> UPDATE_COMPLETE_IND + VM_DISCONNECT + +Two things to get right when this is built: + +- `UPDATE_SYNC_CFM` reports the radio's `UpdateState`, so which phase to run should + be decided from what the radio says rather than tracked locally. That also covers + resuming an interrupted transfer. +- The chunk loop is driven by `UPDATE_DATA_BYTES_REQ`, so the subscription has to be + established before the request that triggers it and held for the whole transfer. + Registering per chunk drops packets. + +The commit and reboot behaviour at the end of phase one is not settled, and differs +between models. +""" + +from __future__ import annotations +import typing as t + +from ._fetch import FirmwareBundle, ProgressCallback + +if t.TYPE_CHECKING: + from ..command import CommandConnection + + +async def flash( + conn: CommandConnection, + bundle: FirmwareBundle, + progress: ProgressCallback | None = None, +) -> None: + """Deliver an assembled firmware image to a connected radio. + + Not implemented. See the module docstring for the protocol. + """ + raise NotImplementedError( + "flashing is not implemented yet: " + "https://github.com/khusmann/benlink/issues/10" + ) From a6c3f4ea864f12440b2421a208ba4c0e68910c1f Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 11:09:34 -0700 Subject: [PATCH 31/85] call flash from the cli update flow --- src/benlink/firmware/__main__.py | 73 +++++++++++++++++++------------- src/benlink/firmware/_fetch.py | 1 + 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index e5f7808..748841e 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -25,6 +25,7 @@ download, download_firmware, extract_base, + flash, oss_base_url, oss_patch_url, ) @@ -233,45 +234,57 @@ async def _cmd_assemble(args: argparse.Namespace) -> int: async def _cmd_update(args: argparse.Namespace) -> int: + # The connection is held for the whole flow: the radio is needed at the start + # to identify it, and again at the end to flash. async with _connection(args) as conn: device_info = await conn.get_device_info() - _print_device_info(device_info) + _print_device_info(device_info) + + _out() + _out("Checking for updates...") + info = await check_update(device_info.product_id) + if info is None: + _out(" no update available") + return 2 + + installed = device_info.firmware_version + latest = info.firmware.version + _out(f" latest v{latest} (you have v{installed})") + _print_update_info(info) + + _out() + if latest == installed: + question = f"Already on v{latest}. Download and assemble anyway?" + if not _confirm(question, False, args.yes): + return 0 + elif not _confirm("Download and assemble?", True, args.yes): + return 0 - _out() - _out("Checking for updates...") - info = await check_update(device_info.product_id) - if info is None: - _out(" no update available") - return 2 + bundle = await download_firmware(info, _make_progress()) + _out() + _out(f" assembled {bundle.size} bytes") + _print_verdict(bundle.data, info.firmware.md5, "the update server") - installed = device_info.firmware_version - latest = info.firmware.version - _out(f" latest v{latest} (you have v{installed})") - _print_update_info(info) + directory = args.keep or tempfile.mkdtemp(prefix="benlink-fw-") + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, f"firmware-v{latest}.bin") + _out() + _write(path, bundle.data, args.force) - _out() - if latest == installed: - question = f"Already on v{latest}. Download and assemble anyway?" - if not _confirm(question, False, args.yes): + _out() + if not _confirm("Flash to radio?", False, args.yes): + _out(f"The assembled image has been kept at {path}") return 0 - elif not _confirm("Download and assemble?", True, args.yes): - return 0 - bundle = await download_firmware(info, _make_progress()) - _out() - _out(f" assembled {bundle.size} bytes") - _print_verdict(bundle.data, info.firmware.md5, "the update server") - - directory = args.keep or tempfile.mkdtemp(prefix="benlink-fw-") - os.makedirs(directory, exist_ok=True) - path = os.path.join(directory, f"firmware-v{info.firmware.version}.bin") - _out() - _write(path, bundle.data, args.force) + try: + await flash(conn, bundle, _make_progress()) + except NotImplementedError as e: + _out(f"error: {e}") + _out(f"The assembled image has been kept at {path}") + return 1 _out() - _out("Flashing is not implemented yet, see " - "https://github.com/khusmann/benlink/issues/10") - _out(f"The assembled image has been kept at {path}") + _out("Firmware update complete.") return 0 diff --git a/src/benlink/firmware/_fetch.py b/src/benlink/firmware/_fetch.py index 73831a0..bb4f2a4 100644 --- a/src/benlink/firmware/_fetch.py +++ b/src/benlink/firmware/_fetch.py @@ -50,6 +50,7 @@ the current release. """ + def _identity(data: bytes) -> bytes: """@private (the RPC messages are encoded by hand; see `_benshikj`)""" return data From 155ff2b2e49763f1efa6d580bb11725f681188df Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sat, 18 Jul 2026 22:08:07 -0700 Subject: [PATCH 32/85] add acknowledgements --- src/benlink/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 41f035d..cec85a4 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -119,6 +119,13 @@ async def main(): [@na7q](https://github.com/na7q) for early testing and feedback +[@Ylianst](https://github.com/Ylianst) for a steady stream of protocol findings +and sharp questions along the way + +[@repins267](https://github.com/repins267) for turning my scattered notes on the +firmware protocol into a complete proof of concept, working out the gRPC update +check, and having the guts to do the first flash. + # Disclaimer This project is an independent grassroots effort, and is **not** affiliated with @@ -133,4 +140,4 @@ async def main(): from . import audio from . import firmware -__all__ = ['controller', 'command', 'audio', 'firmware'] \ No newline at end of file +__all__ = ['controller', 'command', 'audio', 'firmware'] From df4a074ba4c81b71c42383e95fc93db6844706b9 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 11:48:46 -0700 Subject: [PATCH 33/85] implement firmware flashing --- src/benlink/__init__.py | 4 +- src/benlink/command.py | 31 +++ src/benlink/firmware/__init__.py | 13 +- src/benlink/firmware/__main__.py | 70 ++++- src/benlink/firmware/_flash.py | 402 +++++++++++++++++++++++++++-- src/benlink/protocol/command/vm.py | 13 +- tests/test_flash.py | 311 ++++++++++++++++++++++ 7 files changed, 808 insertions(+), 36 deletions(-) create mode 100644 tests/test_flash.py diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index cec85a4..6d8e754 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -106,8 +106,8 @@ async def main(): - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Figure out firmware flashing process / protocol (this is key for long-term - independence from the HT app) +- [ ] Test firmware flashing against a radio. The protocol is worked out and + implemented in `benlink.firmware`, but has never been run on hardware ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library diff --git a/src/benlink/command.py b/src/benlink/command.py index c570a91..f0fb547 100644 --- a/src/benlink/command.py +++ b/src/benlink/command.py @@ -35,6 +35,7 @@ from __future__ import annotations import typing as t import asyncio +import contextlib from . import protocol as p from .common import ImmutableBaseModel from .link import CommandLink, BleCommandLink, RfcommCommandLink @@ -82,6 +83,36 @@ async def send_bytes(self, data: bytes) -> None: async def send_message(self, command: CommandMessage) -> None: await self._link.send(command_message_to_protocol(command)) + async def send_protocol_message(self, msg: p.Message) -> None: + """Send a raw protocol message. + + For messages with no `CommandMessage` equivalent. Not the same as + `send_bytes`: the Rfcomm link wraps what it sends in a `GaiaFrame`. + """ + await self._link.send(msg) + + @contextlib.asynccontextmanager + async def subscribe( + self, + match: t.Callable[[RadioMessage], bool] | None = None, + ) -> t.AsyncIterator[asyncio.Queue[RadioMessage]]: + """Collect matching messages into a queue while the context is held. + + Enter this before sending whatever provokes the replies, so that a reply + arriving faster than the next `await` isn't dropped. + """ + queue: asyncio.Queue[RadioMessage] = asyncio.Queue() + + def handler(msg: RadioMessage) -> None: + if match is None or match(msg): + queue.put_nowait(msg) + + remove_handler = self._add_message_handler(handler) + try: + yield queue + finally: + remove_handler() + async def send_message_expect_reply(self, command: CommandMessage, expect: t.Type[RadioMessageT]) -> RadioMessageT | MessageReplyError: queue: asyncio.Queue[RadioMessageT | MessageReplyError] = asyncio.Queue() diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 90a29a8..b496b84 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -5,8 +5,13 @@ any other damage to your equipment.** This module is not endorsed by or affiliated with Benshi, Vero, RadioOddity, BTech, or any other company. -Downloading and assembling an image is safe. Flashing one is not, and is not -implemented yet ([issue #10](https://github.com/khusmann/benlink/issues/10)). +Downloading and assembling an image is safe. Flashing one is not. + +`flash` reproduces the official app's message sequence byte for byte against +packet captures, but **it has not yet been run against a radio** +([issue #10](https://github.com/khusmann/benlink/issues/10)). The commit step is +also known to differ by model: the UV-Pro reboots itself once the image is +staged, while the VR-N76 reportedly does not. # The intended flow @@ -93,13 +98,15 @@ oss_patch_url, oss_update_info, ) -from ._flash import flash +from ._flash import FlashError, FlashResult, flash __all__ = [ "BASE_IMAGES", "PRODUCTS", "FirmwareBundle", "FirmwareInfo", + "FlashError", + "FlashResult", "ProgressCallback", "UpdateInfo", "assemble", diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 748841e..ded2a8d 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -7,6 +7,7 @@ import typing as t import argparse import asyncio +import contextlib import hashlib import os import sys @@ -18,7 +19,9 @@ from . import ( BASE_IMAGES, PRODUCTS, + FirmwareBundle, FirmwareInfo, + FlashResult, UpdateInfo, assemble, check_update, @@ -30,6 +33,11 @@ oss_patch_url, ) +_REBOOT_WAIT = 20.0 +"""Seconds to let the radio reboot before trying to reach it again.""" + +_COMMIT_ATTEMPTS = 5 + ##################### # Output @@ -104,6 +112,25 @@ def _confirm(question: str, default_yes: bool, assume_yes: bool) -> bool: ##################### # Radio +@contextlib.asynccontextmanager +async def _radio( + args: argparse.Namespace, +) -> t.AsyncGenerator[CommandConnection, None]: + """Connect, and tolerate the radio vanishing on the way out. + + A firmware update ends with the radio rebooting, which drops the link before + anything gets to close it. Raising from the teardown would turn a completed + transfer into a crash. + """ + conn = _connection(args) + await conn.connect() + try: + yield conn + finally: + with contextlib.suppress(Exception): + await conn.disconnect() + + def _connection(args: argparse.Namespace) -> CommandConnection: # Imported lazily: everything except the radio commands works without a # Bluetooth stack. @@ -236,7 +263,7 @@ async def _cmd_assemble(args: argparse.Namespace) -> int: async def _cmd_update(args: argparse.Namespace) -> int: # The connection is held for the whole flow: the radio is needed at the start # to identify it, and again at the end to flash. - async with _connection(args) as conn: + async with _radio(args) as conn: device_info = await conn.get_device_info() _print_device_info(device_info) @@ -276,16 +303,49 @@ async def _cmd_update(args: argparse.Namespace) -> int: _out(f"The assembled image has been kept at {path}") return 0 + _out("Do not power off the radio until this finishes.") try: - await flash(conn, bundle, _make_progress()) - except NotImplementedError as e: + result = await flash(conn, bundle, _make_progress()) + except Exception as e: + _out() _out(f"error: {e}") _out(f"The assembled image has been kept at {path}") return 1 + _out() + + if result is FlashResult.COMPLETE: + _out("Firmware update complete.") + return 0 + + _out(" image staged, radio is rebooting") + return await _commit_after_reboot(args, bundle, path) + + +async def _commit_after_reboot( + args: argparse.Namespace, bundle: FirmwareBundle, path: str +) -> int: + """Reconnect to the rebooted radio and finish the update. + + The radio drops the connection when it reboots and comes back needing only + the commit handshake. It stays in that state until it gets one, so a failed + attempt can simply be retried. + """ + for attempt in range(1, _COMMIT_ATTEMPTS + 1): + await asyncio.sleep(_REBOOT_WAIT) + try: + async with _radio(args) as conn: + if await flash(conn, bundle) is FlashResult.COMPLETE: + _out() + _out("Firmware update complete.") + return 0 + except Exception as e: + _out(f" attempt {attempt}/{_COMMIT_ATTEMPTS} failed: {e}") _out() - _out("Firmware update complete.") - return 0 + _out("error: the radio did not come back to finish the update.") + _out("The image is already staged, so re-running `update` will resume " + f"from here. The assembled image has been kept at {path}") + return 1 ##################### diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 1687014..eb67d47 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -1,13 +1,10 @@ """Delivering an assembled firmware image to a radio. -**Not implemented yet** ([issue #10](https://github.com/khusmann/benlink/issues/10)). - -The message types are in `benlink.protocol.command.vm`; what remains is the state -machine that drives them. The transfer runs over the same command connection as -everything else, in two phases separated by a reboot: +The transfer runs over the same command connection as everything else, in two +phases separated by a reboot: VM_CONNECT - UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM + UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM (DATA_TRANSFER) UPDATE_START_REQ -> UPDATE_START_CFM UPDATE_START_DATA_REQ UPDATE_DATA <- UPDATE_DATA_BYTES_REQ (repeats) @@ -18,43 +15,400 @@ [radio reboots, connection drops, reconnect] VM_CONNECT - UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM + UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM (IN_PROGRESS) UPDATE_START_REQ -> UPDATE_START_CFM UPDATE_IN_PROGRESS_RES -> UPDATE_COMPLETE_IND VM_DISCONNECT -Two things to get right when this is built: - -- `UPDATE_SYNC_CFM` reports the radio's `UpdateState`, so which phase to run should - be decided from what the radio says rather than tracked locally. That also covers - resuming an interrupted transfer. -- The chunk loop is driven by `UPDATE_DATA_BYTES_REQ`, so the subscription has to be - established before the request that triggers it and held for the whole transfer. - Registering per chunk drops packets. - -The commit and reboot behaviour at the end of phase one is not settled, and differs -between models. +`flash` runs one phase per call and reports whether a reboot is pending, so the +caller owns the reconnect. Which phase runs is decided by the `UpdateState` the +radio reports in `UPDATE_SYNC_CFM` rather than tracked locally, which is also +what makes an interrupted update resumable: a half-finished transfer reports +`DATA_TRANSFER` again and `UPDATE_DATA_BYTES_REQ.n_bytes_skip` says where to +pick up. """ from __future__ import annotations +import asyncio import typing as t +from enum import Enum +from .. import protocol as p +from ..protocol.command.bt_notification import ( + BtEventNotificationBody, BtEventType, +) +from ..protocol.command.vm import ( + UpdateState, + VmConnectBody, + VmConnectReplyBody, + VmControlBody, + VmControlReplyBody, + VmControlMessage, + VmControlType, + VmControlUpdateAbortReq, + VmControlUpdateData, + VmControlUpdateDataBytesReq, + VmControlUpdateDataStartReq, + VmControlUpdateError, + VmControlUpdateInProgressRes, + VmControlUpdateIsValidationDoneReq, + VmControlUpdateStartReq, + VmControlUpdateSyncCfm, + VmControlUpdateSyncReq, + VmControlUpdateTransferCompleteRes, + VmDisconnectBody, + VmuPacket, + VmuPacketMessage, + VmuPacketType, +) from ._fetch import FirmwareBundle, ProgressCallback if t.TYPE_CHECKING: - from ..command import CommandConnection + from ..command import ( + CommandConnection, RadioMessage, UnknownProtocolMessage, + ) + +_REPLY_TIMEOUT = 15.0 +_CHUNK_TIMEOUT = 60.0 +_VALIDATION_TIMEOUT = 180.0 +_COMPLETE_TIMEOUT = 180.0 + +# The radio reboots on its own once it accepts UPDATE_TRANSFER_COMPLETE_RES, so +# this byte is not "did the transfer succeed" despite the field name: 0 proceeds +# with the reboot, 1 postpones it. All four successful updates in btsnoop/ send +# 0; the one capture sending 1 is the app's "cancel the restart" button, after +# which the radio sits in TRANSFER_COMPLETE until a later session sends 0. +_REBOOT_NOW = False + + +class FlashResult(Enum): + """What `flash` left the radio doing.""" + + REBOOT_PENDING = "reboot_pending" + """The image is staged. The radio is rebooting and the connection will drop; + reconnect and call `flash` again to finish.""" + + COMPLETE = "complete" + """The update is committed and running.""" async def flash( conn: CommandConnection, bundle: FirmwareBundle, progress: ProgressCallback | None = None, -) -> None: +) -> FlashResult: """Deliver an assembled firmware image to a connected radio. - Not implemented. See the module docstring for the protocol. + Runs whichever phase of the update the radio says it is in, so a full update + is two calls with a reconnect in between: + + if await flash(conn, bundle) is FlashResult.REBOOT_PENDING: + ... reconnect ... + await flash(conn, bundle) + + Passing a `bundle` other than the one already staged is rejected by the radio + at `UPDATE_SYNC_REQ`. """ - raise NotImplementedError( - "flashing is not implemented yet: " - "https://github.com/khusmann/benlink/issues/10" + async with conn.subscribe(_is_vm_message) as inbox: + await _vm_connect(conn, inbox) + + state = (await _sync(conn, inbox, bundle.md5_tail)).update_state + await _start(conn, inbox) + + match state: + case UpdateState.DATA_TRANSFER | UpdateState.VALIDATION: + # Only the transfer is abortable. Once an image is staged the + # radio owns it, and UPDATE_ABORT_REQ would throw it away. + try: + if state is UpdateState.DATA_TRANSFER: + await _transfer(conn, inbox, bundle, progress) + # VALIDATION appears in no capture. The image is already + # delivered in that state, so ask whether the checksum + # finished rather than sending all of it again. + await _validate(conn, inbox) + except Exception: + await _abort(conn) + raise + await _request_reboot(conn) + return FlashResult.REBOOT_PENDING + + case UpdateState.TRANSFER_COMPLETE: + await _request_reboot(conn) + return FlashResult.REBOOT_PENDING + + case UpdateState.IN_PROGRESS: + await _finalize(conn, inbox) + return FlashResult.COMPLETE + + case UpdateState.COMMIT: + # UPDATE_COMMIT_CFM exists, but no capture shows the app in this + # state or sending it, so there is nothing to copy. Stopping + # leaves the staged image intact for the app to finish. + raise FlashError( + "radio reports the COMMIT state, which benlink has never " + "observed and does not know how to answer" + ) + + +##################### +# Phases + +async def _transfer( + conn: CommandConnection, + inbox: asyncio.Queue[RadioMessage], + bundle: FirmwareBundle, + progress: ProgressCallback | None, +) -> None: + """Send the image, one device-requested chunk at a time.""" + await _send_control( + conn, VmControlType.UPDATE_START_DATA_REQ, VmControlUpdateDataStartReq() + ) + + data = bundle.data + total = len(data) + offset = 0 + + while offset < total: + req = await _recv_vmu_as( + inbox, + VmuPacketType.UPDATE_DATA_BYTES_REQ, + VmControlUpdateDataBytesReq, + timeout=_CHUNK_TIMEOUT, + ) + + # Non-zero only when the radio already holds part of the image. + offset += req.n_bytes_skip + + chunk = data[offset:offset + req.n_bytes_requested] + if not chunk: + raise FlashError( + f"radio asked for {req.n_bytes_requested} bytes at offset " + f"{offset}, past the end of a {total} byte image" + ) + + offset += len(chunk) + + await _send_control( + conn, + VmControlType.UPDATE_DATA, + VmControlUpdateData( + is_final_fragment=offset >= total, + data=chunk, + ), + ) + + if progress is not None: + progress("flash", offset, total) + + +async def _validate( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage] +) -> None: + """Wait for the radio to checksum what it received.""" + await _send_control( + conn, + VmControlType.UPDATE_IS_VALIDATION_DONE_REQ, + VmControlUpdateIsValidationDoneReq(), + ) + await _recv_vmu( + inbox, + VmuPacketType.UPDATE_TRANSFER_COMPLETE_IND, + timeout=_VALIDATION_TIMEOUT, + ) + + +async def _request_reboot(conn: CommandConnection) -> None: + """Release the staged image. The radio reboots into it on its own.""" + await _send_control( + conn, + VmControlType.UPDATE_TRANSFER_COMPLETE_RES, + VmControlUpdateTransferCompleteRes(is_complete=_REBOOT_NOW), + ) + + +async def _finalize( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage] +) -> None: + """Commit the staged image on the rebooted radio.""" + await _send_control( + conn, VmControlType.UPDATE_IN_PROGRESS_RES, VmControlUpdateInProgressRes() + ) + await _recv_vmu( + inbox, VmuPacketType.UPDATE_COMPLETE_IND, timeout=_COMPLETE_TIMEOUT + ) + + await conn.send_protocol_message( + _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) + ) + + +async def _vm_connect( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage] +) -> None: + await conn.send_protocol_message( + _message(p.ExtendedCommand.VM_CONNECT, VmConnectBody()) + ) + reply = await _recv_connect_reply(inbox) + if reply.status != p.ReplyStatus.SUCCESS: + raise FlashError(f"VM_CONNECT rejected: {reply.status.name}") + + +async def _sync( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage], md5_tail: bytes +) -> VmControlUpdateSyncCfm: + await _send_control( + conn, + VmControlType.UPDATE_SYNC_REQ, + VmControlUpdateSyncReq(md5sum_tail=md5_tail), + ) + return await _recv_vmu_as( + inbox, VmuPacketType.UPDATE_SYNC_CFM, VmControlUpdateSyncCfm + ) + + +async def _start( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage] +) -> None: + await _send_control( + conn, VmControlType.UPDATE_START_REQ, VmControlUpdateStartReq() + ) + # UPDATE_START_CFM carries a cfm_code, but every capture reports OK in both + # phases, so nothing here can be keyed off it. + await _recv_vmu(inbox, VmuPacketType.UPDATE_START_CFM) + + +async def _abort(conn: CommandConnection) -> None: + """Best effort: the original failure is what the caller needs to see.""" + try: + await _send_control( + conn, VmControlType.UPDATE_ABORT_REQ, VmControlUpdateAbortReq() + ) + except Exception: + pass + + +##################### +# Transport + +class FlashError(Exception): + """The radio rejected or abandoned the update.""" + + +def _message(command: p.ExtendedCommand, body: t.Any) -> p.Message: + return p.Message( + command_group=p.CommandGroup.EXTENDED, + is_reply=False, + command=command, + body=body, ) + + +async def _send_control( + conn: CommandConnection, control_type: VmControlType, msg: VmControlMessage +) -> None: + await conn.send_protocol_message(_message( + p.ExtendedCommand.VM_CONTROL, + VmControlBody( + vm_control_type=control_type, + # Not msg.length(), which is None for the dynamically sized bodies. + n_bytes_payload=len(msg.to_bytes()), + msg=msg, + ), + )) + + +def _is_vm_message(msg: RadioMessage) -> bool: + from ..command import UnknownProtocolMessage + + if not isinstance(msg, UnknownProtocolMessage): + return False + body = msg.message.body + if isinstance(body, (VmConnectReplyBody, VmControlReplyBody)): + return True + return ( + isinstance(body, BtEventNotificationBody) + and body.bt_event_type == BtEventType.VMU_PACKET + ) + + +_T = t.TypeVar("_T") + + +async def _with_timeout( + receive: t.Coroutine[t.Any, t.Any, _T], timeout: float, described_as: str +) -> _T: + # asyncio.timeout would read better, but it is 3.11+ and this package + # supports 3.10. + try: + return await asyncio.wait_for(receive, timeout) + except asyncio.TimeoutError: + raise FlashError( + f"radio went quiet: no {described_as} within {timeout:g}s" + ) from None + + +async def _recv_body(inbox: asyncio.Queue[RadioMessage]) -> t.Any: + """`_is_vm_message` has already established that these are VM messages.""" + msg = t.cast("UnknownProtocolMessage", await inbox.get()) + return msg.message.body + + +async def _recv_connect_reply( + inbox: asyncio.Queue[RadioMessage], timeout: float = _REPLY_TIMEOUT +) -> VmConnectReplyBody: + async def receive() -> VmConnectReplyBody: + while True: + body = await _recv_body(inbox) + if isinstance(body, VmConnectReplyBody): + return body + + return await _with_timeout(receive(), timeout, "VM_CONNECT reply") + + +async def _recv_vmu( + inbox: asyncio.Queue[RadioMessage], + expect: VmuPacketType, + timeout: float = _REPLY_TIMEOUT, +) -> VmuPacketMessage | bytes: + """Wait for a VMU packet of `expect`. + + The `VM_CONTROL` reply that comes back first only acknowledges receipt of + the control message; the answer always follows separately as a VMU packet. + An `UPDATE_ERROR` is raised here rather than left to time out. + """ + async def receive() -> VmuPacketMessage | bytes: + while True: + body = await _recv_body(inbox) + if not isinstance(body, BtEventNotificationBody): + continue + + packet = body.bt_event + if not isinstance(packet, VmuPacket): + continue + + if isinstance(packet.msg, VmControlUpdateError): + raise FlashError( + f"radio reported {packet.msg.update_error.name} while " + f"waiting for {expect.name}" + ) + + if packet.vmu_packet_type == expect: + return packet.msg + + return await _with_timeout(receive(), timeout, expect.name) + + +_VmuT = t.TypeVar("_VmuT", bound=VmuPacketMessage) + + +async def _recv_vmu_as( + inbox: asyncio.Queue[RadioMessage], + expect: VmuPacketType, + as_type: t.Type[_VmuT], + timeout: float = _REPLY_TIMEOUT, +) -> _VmuT: + """`_recv_vmu` for the packets whose fields are actually read.""" + msg = await _recv_vmu(inbox, expect, timeout) + if not isinstance(msg, as_type): + raise FlashError(f"could not parse {expect.name}: {msg!r}") + return msg diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 5f3589f..5ca8fa1 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -15,9 +15,10 @@ # d. (UPDATE_DATA_BYTES_REQ) UPDATE_DATA (145 bytes at a time. repeat until all data is sent, except for the last fragment) # e. UPDATE_DATA (final fragment with is_final_fragment=True) # f. UPDATE_IS_VALIDATION_DONE_REQ (UPDATE_TRANSFER_COMPLETE_IND) -# g. UPDATE_TRANSFER_COMPLETE_RES (triggers REStart?) +# g. UPDATE_TRANSFER_COMPLETE_RES (with is_complete=False; triggers reboot) # -# Reboot happens? +# Reboot happens here, and the connection drops. UPDATE_SYNC_CFM reports +# IN_PROGRESS afterwards, which is what tells the app to resume at step 3. # # 3. VM_CONNECT # h. UPDATE_SYNC_REQ (UPDATE_SYNC_CFM) (with last 4 bytes of firmware md5sum) @@ -112,6 +113,11 @@ class VmControlUpdateIsValidationDoneReq(Bitfield): class VmControlUpdateTransferCompleteRes(Bitfield): + # Misleading name: this is the app's answer to "reboot into the new image + # now?", not a report on the transfer. False proceeds with the reboot (every + # successful update in the logs), True postpones it and leaves the radio in + # UpdateState.TRANSFER_COMPLETE, which is what the app's "cancel restart" + # button does. is_complete: bool = bf_bool_byte @@ -133,6 +139,9 @@ class UpdateState(IntEnum): class UpdateStartCfmCode(IntEnum): OK = 0 + # Not seen in logs. Every UPDATE_START_CFM reports OK, including the + # post-reboot one, so the phase of an update has to be read off + # UPDATE_SYNC_CFM.update_state rather than from this code. GOTO_NEXT_STATE = 9 diff --git a/tests/test_flash.py b/tests/test_flash.py new file mode 100644 index 0000000..6b75889 --- /dev/null +++ b/tests/test_flash.py @@ -0,0 +1,311 @@ +import asyncio +import typing as t + +import pytest + +from benlink.command import CommandConnection +from benlink.firmware import FirmwareBundle, FirmwareInfo, UpdateInfo, flash +from benlink.firmware._flash import FlashError, FlashResult +import benlink.protocol as p +from benlink.protocol.command.bt_notification import ( + BtEventNotificationBody, BtEventType, +) +from benlink.protocol.command.vm import ( + UpdateError, + UpdateState, + VmConnectReplyBody, + VmControlReplyBody, + VmControlType, + VmControlUpdateDataBytesReq, + VmControlUpdateError, + VmControlUpdateStartCfm, + VmControlUpdateSyncCfm, + VmuPacket, + VmuPacketType, + UpdateStartCfmCode, + VmControlBody, + VmControlUpdateCompleteInd, + VmControlUpdateTransferCompleteInd, +) + +CHUNK = 145 + + +def _bundle(data: bytes) -> FirmwareBundle: + return FirmwareBundle( + data=data, + update_info=UpdateInfo( + firmware=FirmwareInfo(version=147, url="", md5=None), + base=FirmwareInfo(version=1, url="", md5=None), + ), + ) + + +class FakeRadio: + """A radio that answers the update messages the way the captures do. + + Everything is round-tripped through `to_bytes`/`from_bytes` so the test + exercises real serialization in both directions. + """ + + def __init__( + self, + state: UpdateState = UpdateState.DATA_TRANSFER, + chunk: int = CHUNK, + skip_first: int = 0, + error_after: int | None = None, + ): + self.state = state + self.chunk = chunk + self.skip_first = skip_first + self.error_after = error_after + self.received = bytearray() + self.sent: t.List[VmControlType] = [] + self.final_flags: t.List[bool] = [] + self.error_on_finalize = False + self.disconnected = False + self.aborted = False + self._callback: t.Any = None + self._chunks_served = 0 + + # CommandLink + + def is_connected(self) -> bool: + return True + + async def connect(self, callback: t.Any) -> None: + self._callback = callback + + async def disconnect(self) -> None: + pass + + async def send_bytes(self, data: bytes) -> None: + raise AssertionError("flash should not use send_bytes") + + async def send(self, msg: p.Message) -> None: + self._handle(p.Message.from_bytes(msg.to_bytes())) + + # Radio behaviour + + def _emit(self, command: p.ExtendedCommand, body: t.Any, is_reply: bool) -> None: + out = p.Message( + command_group=p.CommandGroup.EXTENDED, + is_reply=is_reply, + command=command, + body=body, + ) + self._callback(p.Message.from_bytes(out.to_bytes())) + + def _emit_vmu(self, packet_type: VmuPacketType, msg: t.Any) -> None: + packet = VmuPacket( + vmu_packet_type=packet_type, + n_bytes_payload=len(msg.to_bytes()), + msg=msg, + ) + self._emit( + p.ExtendedCommand.BT_EVENT_NOTIFICATION, + BtEventNotificationBody( + bt_event_type=BtEventType.VMU_PACKET, bt_event=packet + ), + is_reply=False, + ) + + def _request_bytes(self) -> None: + skip = self.skip_first if self._chunks_served == 0 else 0 + self._chunks_served += 1 + self._emit_vmu( + VmuPacketType.UPDATE_DATA_BYTES_REQ, + VmControlUpdateDataBytesReq( + n_bytes_requested=self.chunk, n_bytes_skip=skip + ), + ) + + def _handle(self, msg: p.Message) -> None: + if msg.command == p.ExtendedCommand.VM_CONNECT: + self._emit( + p.ExtendedCommand.VM_CONNECT, + VmConnectReplyBody(status=p.ReplyStatus.SUCCESS), + is_reply=True, + ) + return + + if msg.command == p.ExtendedCommand.VM_DISCONNECT: + self.disconnected = True + return + + body = msg.body + assert isinstance(body, VmControlBody) + self.sent.append(body.vm_control_type) + + # Every VM_CONTROL is acknowledged before the answer arrives. + self._emit( + p.ExtendedCommand.VM_CONTROL, + VmControlReplyBody(status=p.ReplyStatus.SUCCESS), + is_reply=True, + ) + + match body.vm_control_type: + case VmControlType.UPDATE_SYNC_REQ: + self._emit_vmu( + VmuPacketType.UPDATE_SYNC_CFM, + VmControlUpdateSyncCfm( + update_state=self.state, + md5sum_tail=body.msg.md5sum_tail, + unknown=b"\x00", + ), + ) + case VmControlType.UPDATE_START_REQ: + self._emit_vmu( + VmuPacketType.UPDATE_START_CFM, + VmControlUpdateStartCfm( + cfm_code=UpdateStartCfmCode.OK, unknown=b"\x00\x00" + ), + ) + case VmControlType.UPDATE_START_DATA_REQ: + self._request_bytes() + case VmControlType.UPDATE_DATA: + self.received += body.msg.data + self.final_flags.append(body.msg.is_final_fragment) + if self.error_after is not None and \ + len(self.received) >= self.error_after: + self._emit_vmu( + VmuPacketType.UPDATE_ERROR, + VmControlUpdateError( + update_error=UpdateError.BATTERY_LOW), + ) + elif not body.msg.is_final_fragment: + self._request_bytes() + case VmControlType.UPDATE_IS_VALIDATION_DONE_REQ: + self._emit_vmu( + VmuPacketType.UPDATE_TRANSFER_COMPLETE_IND, + VmControlUpdateTransferCompleteInd(), + ) + case VmControlType.UPDATE_IN_PROGRESS_RES: + if self.error_on_finalize: + self._emit_vmu( + VmuPacketType.UPDATE_ERROR, + VmControlUpdateError(update_error=UpdateError.UNKNOWN), + ) + else: + self._emit_vmu( + VmuPacketType.UPDATE_COMPLETE_IND, + VmControlUpdateCompleteInd(), + ) + case VmControlType.UPDATE_ABORT_REQ: + self.aborted = True + + +def _run(radio: FakeRadio, bundle: FirmwareBundle, **kwargs: t.Any) -> FlashResult: + async def main() -> FlashResult: + conn = CommandConnection(radio) + await conn.connect() + return await flash(conn, bundle, **kwargs) + + return asyncio.run(main()) + + +def test_transfer_phase_sends_whole_image(): + data = bytes(range(256)) * 5 + radio = FakeRadio() + + result = _run(radio, _bundle(data)) + + assert result is FlashResult.REBOOT_PENDING + assert bytes(radio.received) == data + assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES + + +def test_final_fragment_is_flagged_once_at_the_end(): + data = b"x" * (CHUNK * 3) + radio = FakeRadio() + _run(radio, _bundle(data)) + + # The radio stops asking for more only because the last UPDATE_DATA said so, + # and an image that divides evenly into chunks must still flag its last one. + assert radio.final_flags == [False, False, True] + + +def test_image_shorter_than_one_chunk(): + data = b"tiny" + radio = FakeRadio() + + assert _run(radio, _bundle(data)) is FlashResult.REBOOT_PENDING + assert bytes(radio.received) == data + + +def test_progress_reports_reach_the_total(): + data = b"y" * (CHUNK * 2 + 7) + seen: t.List[t.Tuple[str, int, int]] = [] + _run(FakeRadio(), _bundle(data), progress=lambda *a: seen.append(a)) + + assert [n for _, n, _ in seen] == [CHUNK, CHUNK * 2, len(data)] + assert all(total == len(data) for _, _, total in seen) + + +def test_resume_honours_n_bytes_skip(): + data = bytes(range(256)) * 4 + radio = FakeRadio(skip_first=300) + + _run(radio, _bundle(data)) + + # The radio already had the first 300 bytes, so they are never resent. + assert bytes(radio.received) == data[300:] + + +def test_in_progress_state_finalizes_instead_of_transferring(): + radio = FakeRadio(state=UpdateState.IN_PROGRESS) + + result = _run(radio, _bundle(b"unused")) + + assert result is FlashResult.COMPLETE + assert VmControlType.UPDATE_DATA not in radio.sent + assert VmControlType.UPDATE_IN_PROGRESS_RES in radio.sent + assert radio.disconnected + + +def test_transfer_complete_state_only_asks_for_the_reboot(): + radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE) + + result = _run(radio, _bundle(b"unused")) + + assert result is FlashResult.REBOOT_PENDING + assert VmControlType.UPDATE_DATA not in radio.sent + assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES + + +def test_reboot_request_asks_the_radio_to_restart_now(): + """The byte is 0 on the app's success path; 1 is its "cancel restart".""" + radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE) + sent: t.List[p.Message] = [] + original = radio.send + + async def record(msg: p.Message) -> None: + sent.append(msg) + await original(msg) + + radio.send = record # type: ignore[method-assign] + _run(radio, _bundle(b"unused")) + + final = sent[-1] + assert isinstance(final.body, VmControlBody) + assert final.body.msg.is_complete is False + + +def test_update_error_is_raised_not_waited_out(): + radio = FakeRadio(error_after=CHUNK) + + with pytest.raises(FlashError, match="BATTERY_LOW"): + _run(radio, _bundle(b"z" * CHUNK * 10)) + + assert radio.aborted + + +def test_failure_after_staging_does_not_abort(): + """Aborting here would discard an image the radio has already validated.""" + radio = FakeRadio(state=UpdateState.IN_PROGRESS) + radio.error_on_finalize = True + + with pytest.raises(FlashError): + _run(radio, _bundle(b"unused")) + + assert not radio.aborted From a1e539e6ca9e53c394e44a61bce989d3fca64646 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:07:16 -0700 Subject: [PATCH 34/85] drop the untested-on-hardware caveats from the flashing docs --- src/benlink/__init__.py | 5 ++--- src/benlink/firmware/__init__.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 6d8e754..0ca9029 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -106,9 +106,8 @@ async def main(): - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Test firmware flashing against a radio. The protocol is worked out and - implemented in `benlink.firmware`, but has never been run on hardware - ([issue](https://github.com/khusmann/benlink/issues/10)) +- [ ] Confirm the firmware commit / reboot handshake on models other than the + UV-Pro ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index b496b84..21bf3de 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -8,10 +8,9 @@ Downloading and assembling an image is safe. Flashing one is not. `flash` reproduces the official app's message sequence byte for byte against -packet captures, but **it has not yet been run against a radio** -([issue #10](https://github.com/khusmann/benlink/issues/10)). The commit step is -also known to differ by model: the UV-Pro reboots itself once the image is -staged, while the VR-N76 reportedly does not. +packet captures. The commit step is known to differ by model: the UV-Pro reboots +itself once the image is staged, while the VR-N76 reportedly does not +([issue #10](https://github.com/khusmann/benlink/issues/10)). # The intended flow From 67425063699bad5195fe4a79734047790e76abff Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:09:17 -0700 Subject: [PATCH 35/85] move the model-dependent reboot note next to the code it describes --- src/benlink/firmware/__init__.py | 7 ------- src/benlink/firmware/_flash.py | 9 ++++++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 21bf3de..7515961 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -5,13 +5,6 @@ any other damage to your equipment.** This module is not endorsed by or affiliated with Benshi, Vero, RadioOddity, BTech, or any other company. -Downloading and assembling an image is safe. Flashing one is not. - -`flash` reproduces the official app's message sequence byte for byte against -packet captures. The commit step is known to differ by model: the UV-Pro reboots -itself once the image is staged, while the VR-N76 reportedly does not -([issue #10](https://github.com/khusmann/benlink/issues/10)). - # The intended flow Firmware ships as a shared **base image** plus a per-release **patch** in BSDIFF40 diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index eb67d47..65e6345 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -218,7 +218,14 @@ async def _validate( async def _request_reboot(conn: CommandConnection) -> None: - """Release the staged image. The radio reboots into it on its own.""" + """Release the staged image, which the radio reboots into on its own. + + Model-dependent, and the least settled step here: the UV-Pro reboots by + itself, while the VR-N76 reportedly does not + ([issue #10](https://github.com/khusmann/benlink/issues/10)). A radio that + stays put reports `TRANSFER_COMPLETE` on the next `flash`, which lands back + here rather than making progress. + """ await _send_control( conn, VmControlType.UPDATE_TRANSFER_COMPLETE_RES, From 1da1744ab454cab6bcbf84632fd89d42878755d2 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:11:18 -0700 Subject: [PATCH 36/85] note that flash needs the bluetooth stack too --- src/benlink/firmware/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 7515961..bc7d496 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -28,7 +28,7 @@ # The pieces Each step is also available alone, for archiving old releases or working away from -the radio. Everything but `info` avoids the Bluetooth stack. +the radio. Everything but `info` and `flash` avoids the Bluetooth stack. ```bash # which radio is this? From 2bc57358cbb4ea9084cd1c1a2026de9e8bc8796a Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:14:08 -0700 Subject: [PATCH 37/85] record why 259 radios cannot get different firmware --- src/benlink/firmware/_fetch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/benlink/firmware/_fetch.py b/src/benlink/firmware/_fetch.py index bb4f2a4..3ad866a 100644 --- a/src/benlink/firmware/_fetch.py +++ b/src/benlink/firmware/_fetch.py @@ -34,7 +34,9 @@ Every patch name here was returned by the update server for the corresponding product id. Note that 259 covers both the VR-N76 and the GA-5WB, which share a patch series, confirmed by a GA-5WB flash capture whose `md5sum_tail` matches -`patch_base_to_vr_n76.v120` assembled against the shared base. +`patch_base_to_vr_n76.v120` assembled against the shared base. They cannot differ: +`CheckFirmwareUpdateRequest` carries no vendor id, so the server cannot tell the +two apart. """ BASE_IMAGES: t.Dict[str, str] = { From 5a5634431627bc3d342a4a728938c0b143ca9e8c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:18:31 -0700 Subject: [PATCH 38/85] correct the reboot note: the ga5wb auto-reboots too --- src/benlink/__init__.py | 5 +++-- src/benlink/firmware/_flash.py | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 0ca9029..b2263cd 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -106,8 +106,9 @@ async def main(): - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Confirm the firmware commit / reboot handshake on models other than the - UV-Pro ([issue](https://github.com/khusmann/benlink/issues/10)) +- [ ] Confirm the firmware reboot handshake on a VR-N76, which is reported not + to reboot itself even though the GA-5WB takes the same image and does + ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 65e6345..6e4293f 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -220,11 +220,10 @@ async def _validate( async def _request_reboot(conn: CommandConnection) -> None: """Release the staged image, which the radio reboots into on its own. - Model-dependent, and the least settled step here: the UV-Pro reboots by - itself, while the VR-N76 reportedly does not - ([issue #10](https://github.com/khusmann/benlink/issues/10)). A radio that - stays put reports `TRANSFER_COMPLETE` on the next `flash`, which lands back - here rather than making progress. + Both radios in the captures reboot here: the UV-Pro (260) and the GA-5WB + (259), which takes the same image as the VR-N76. Issue #10 reports the + VR-N76 not rebooting, which that leaves unexplained. A radio that does stay + put reports `TRANSFER_COMPLETE` and lands back here. """ await _send_control( conn, From a80a159b7cd03b321f2f5f95c8c06465fafc3c4d Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:20:47 -0700 Subject: [PATCH 39/85] sharpen the vr-n76 reboot note against what issue #10 actually reports --- src/benlink/__init__.py | 4 ++-- src/benlink/firmware/_flash.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index b2263cd..687e97b 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -106,8 +106,8 @@ async def main(): - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Confirm the firmware reboot handshake on a VR-N76, which is reported not - to reboot itself even though the GA-5WB takes the same image and does +- [ ] Confirm the firmware commit handshake on a VR-N76, where staging works but + the radio has been reported to come back on the old image ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 6e4293f..bb4ace3 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -221,9 +221,11 @@ async def _request_reboot(conn: CommandConnection) -> None: """Release the staged image, which the radio reboots into on its own. Both radios in the captures reboot here: the UV-Pro (260) and the GA-5WB - (259), which takes the same image as the VR-N76. Issue #10 reports the - VR-N76 not rebooting, which that leaves unexplained. A radio that does stay - put reports `TRANSFER_COMPLETE` and lands back here. + (259), which takes the same image as the VR-N76. Issue #10 reports a VR-N76 + that does not, but describes a radio rebooting on this very byte and coming + back on the old bank — a commit that never completed rather than a reboot + that never happened. A radio that does stay put reports `TRANSFER_COMPLETE` + and lands back here. """ await _send_control( conn, From 0898ccb9fcd79933e26e9b156017dfd91549f94c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:22:56 -0700 Subject: [PATCH 40/85] trim the reboot note to what the captures confirm --- src/benlink/firmware/_flash.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index bb4ace3..7b1227d 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -220,12 +220,9 @@ async def _validate( async def _request_reboot(conn: CommandConnection) -> None: """Release the staged image, which the radio reboots into on its own. - Both radios in the captures reboot here: the UV-Pro (260) and the GA-5WB - (259), which takes the same image as the VR-N76. Issue #10 reports a VR-N76 - that does not, but describes a radio rebooting on this very byte and coming - back on the old bank — a commit that never completed rather than a reboot - that never happened. A radio that does stay put reports `TRANSFER_COMPLETE` - and lands back here. + Confirmed on both radios in the captures, the UV-Pro (260) and the GA-5WB + (259). One that stays put instead reports `TRANSFER_COMPLETE` and lands back + here. """ await _send_control( conn, From ad0597d4330a018a074337c7c2dbba350680a0f1 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:23:38 -0700 Subject: [PATCH 41/85] drop the firmware commit handshake todo --- src/benlink/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 687e97b..0fc4b45 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -106,9 +106,6 @@ async def main(): - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Confirm the firmware commit handshake on a VR-N76, where staging works but - the radio has been reported to come back on the old image - ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library From 2a4195037dee84878c15ded13e1dc10ec16bb025 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:27:24 -0700 Subject: [PATCH 42/85] mark the vr-n76 as tested --- README.md | 12 ++++++++---- src/benlink/__init__.py | 2 +- update_readme.py | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 235cbf4..4ceb9be 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The following radios should work with this library: - BTech UV-Pro - RadioOddity GA-5WB -- Vero VR-N76 (untested) +- Vero VR-N76 - Vero VR-N7500 (untested) - BTech GMRS-Pro (untested) @@ -113,9 +113,6 @@ Things to do: - [ ] Make a higher-level interface for sending / receiving TNC data (auto retry, queue message fragments) ([issue](https://github.com/khusmann/benlink/issues/1)) -- [ ] Figure out firmware flashing process / protocol (this is key for long-term - independence from the HT app) - ([issue](https://github.com/khusmann/benlink/issues/10)) - [ ] Implement more commands and settings - [ ] Find more radios that use this protocol and test them with this library @@ -126,6 +123,13 @@ receive [@na7q](https://github.com/na7q) for early testing and feedback +[@Ylianst](https://github.com/Ylianst) for a steady stream of protocol findings +and sharp questions along the way + +[@repins267](https://github.com/repins267) for turning my scattered notes on the +firmware protocol into a complete proof of concept, working out the gRPC update +check, and having the guts to do the first flash. + ## Disclaimer This project is an independent grassroots effort, and is **not** affiliated with diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 0fc4b45..fabb7e5 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -33,7 +33,7 @@ - BTech UV-Pro - RadioOddity GA-5WB -- Vero VR-N76 (untested) +- Vero VR-N76 - Vero VR-N7500 (untested) - BTech GMRS-Pro (untested) diff --git a/update_readme.py b/update_readme.py index 18e3edb..321e291 100755 --- a/update_readme.py +++ b/update_readme.py @@ -25,7 +25,9 @@ raise ValueError("No content section found in README.md.") readme_content_stripped = [ - line[1:] if line.startswith("##") else line + # Backslashes are doubled because the content lands inside a docstring, where + # markdown escapes like \_ would otherwise be invalid escape sequences. + (line[1:] if line.startswith("##") else line).replace("\\", "\\\\") for line in readme_content[readme_start+1:] ] @@ -36,6 +38,6 @@ *init_content[docstring_end:] ] -init_path.write_text("\n".join(updated_content)) +init_path.write_text("\n".join(updated_content) + "\n") print(f"README content has been updated into module definition") From df2917f41b4370e9ae7dca46206e3c4a3b4833b6 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:30:24 -0700 Subject: [PATCH 43/85] lead the firmware docs with a breakage warning; credit the flashing poc --- README.md | 4 ++-- src/benlink/__init__.py | 4 ++-- src/benlink/firmware/__init__.py | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4ceb9be..a2e5a8d 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,8 @@ receive and sharp questions along the way [@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete proof of concept, working out the gRPC update -check, and having the guts to do the first flash. +firmware protocol into a complete flashing proof of concept, working out the gRPC +update check, and having the guts to do the first flash. ## Disclaimer diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index fabb7e5..831c26c 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -120,8 +120,8 @@ async def main(): and sharp questions along the way [@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete proof of concept, working out the gRPC update -check, and having the guts to do the first flash. +firmware protocol into a complete flashing proof of concept, working out the gRPC +update check, and having the guts to do the first flash. # Disclaimer diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index bc7d496..e4105b7 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -1,4 +1,9 @@ """ +# THIS CAN BREAK YOUR RADIO + +**Flashing firmware can leave your radio unusable, and nothing in this library can +undo it.** Downloading and assembling images is safe; `flash` and `update` are not. + # Disclaimer **Use this at your own risk. I am not responsible for bricking your radio, or for From e19ce4a26448845337e3ce7dfb903c99b9f15779 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:31:00 -0700 Subject: [PATCH 44/85] trim the breakage warning to one line --- src/benlink/firmware/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index e4105b7..5fbd33a 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -2,7 +2,7 @@ # THIS CAN BREAK YOUR RADIO **Flashing firmware can leave your radio unusable, and nothing in this library can -undo it.** Downloading and assembling images is safe; `flash` and `update` are not. +undo it.** # Disclaimer From 65fc1050495c31d5c90824eed199770c9e8c6e4b Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:31:34 -0700 Subject: [PATCH 45/85] warn on the flash docstring too --- src/benlink/firmware/_flash.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 7b1227d..efd42d0 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -99,6 +99,8 @@ async def flash( ) -> FlashResult: """Deliver an assembled firmware image to a connected radio. + **This can break your radio, and nothing here can undo it.** + Runs whichever phase of the update the radio says it is in, so a full update is two calls with a reconnect in between: From 0123fb86a49ddfdfd4f498ade89a90ff12f815c8 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:36:08 -0700 Subject: [PATCH 46/85] say what the server's md5s actually cover in check output --- src/benlink/firmware/__main__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index ded2a8d..885796c 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -79,15 +79,17 @@ def _print_verdict(data: bytes, expected: str | None, source: str) -> None: def _print_update_info(info: UpdateInfo) -> None: - def show(label: str, entry: FirmwareInfo) -> None: + def show(label: str, entry: FirmwareInfo, md5_covers: str) -> None: # The server populates version for the patch but not for the base image. _out(f" {label} v{entry.version}" if entry.version else f" {label}") _out(f" url {entry.url}") if entry.md5: - _out(f" md5 {entry.md5}") + # Neither md5 describes the file at the url above, which is easy to + # assume and wrong. + _out(f" md5 {entry.md5} ({md5_covers})") - show("patch", info.firmware) - show("base", info.base) + show("patch", info.firmware, "of the assembled image") + show("base", info.base, "of the extracted .bin") def _write(path: str, data: bytes, force: bool) -> None: From 10454ccf8642cfc4b30cc2294aa6229f2f26ac42 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:38:44 -0700 Subject: [PATCH 47/85] rename _benshikj to _rpc --- src/benlink/firmware/_fetch.py | 14 +++++----- .../firmware/{_benshikj.py => _rpc.py} | 0 tests/test_firmware.py | 28 +++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) rename src/benlink/firmware/{_benshikj.py => _rpc.py} (100%) diff --git a/src/benlink/firmware/_fetch.py b/src/benlink/firmware/_fetch.py index 3ad866a..1ebfee4 100644 --- a/src/benlink/firmware/_fetch.py +++ b/src/benlink/firmware/_fetch.py @@ -12,7 +12,7 @@ import zipfile from ..common import ImmutableBaseModel -from . import _benshikj +from . import _rpc OSS_BASE_URL = "https://pubdatas.oss-cn-shenzhen.aliyuncs.com" """@private""" @@ -54,7 +54,7 @@ def _identity(data: bytes) -> bytes: - """@private (the RPC messages are encoded by hand; see `_benshikj`)""" + """@private (the RPC messages are encoded by hand; see `_rpc`)""" return data @@ -82,7 +82,7 @@ class FirmwareInfo(ImmutableBaseModel): but the current one.""" @classmethod - def from_protocol(cls, info: _benshikj.FirmwareInfo) -> FirmwareInfo: + def from_protocol(cls, info: _rpc.FirmwareInfo) -> FirmwareInfo: """@private (Protocol helper)""" return cls(version=info.version, url=info.url, md5=info.md5 or None) @@ -94,7 +94,7 @@ class UpdateInfo(ImmutableBaseModel): @classmethod def from_protocol( - cls, result: _benshikj.CheckFirmwareUpdateResult + cls, result: _rpc.CheckFirmwareUpdateResult ) -> UpdateInfo | None: """@private (Protocol helper)""" if not result.firmware.url or not result.base.url: @@ -149,12 +149,12 @@ async def check_update( """ grpc = _require("grpc", "grpcio") - request = _benshikj.encode_check_request(product_id, firmware_version) + request = _rpc.encode_check_request(product_id, firmware_version) credentials = grpc.ssl_channel_credentials() async with grpc.aio.secure_channel(RPC_HOST, credentials) as channel: call = channel.unary_unary( - _benshikj.METHOD, + _rpc.METHOD, request_serializer=_identity, response_deserializer=_identity, ) @@ -164,7 +164,7 @@ async def check_update( raise RuntimeError( f"update check failed: {e.code()} {e.details()}") - return UpdateInfo.from_protocol(_benshikj.decode_check_result(response)) + return UpdateInfo.from_protocol(_rpc.decode_check_result(response)) def oss_patch_url(version: int, patch_name: str) -> str: diff --git a/src/benlink/firmware/_benshikj.py b/src/benlink/firmware/_rpc.py similarity index 100% rename from src/benlink/firmware/_benshikj.py rename to src/benlink/firmware/_rpc.py diff --git a/tests/test_firmware.py b/tests/test_firmware.py index 405dc5e..6938e6c 100644 --- a/tests/test_firmware.py +++ b/tests/test_firmware.py @@ -3,7 +3,7 @@ import pytest -from benlink.firmware import _benshikj +from benlink.firmware import _rpc from benlink.firmware import ( PRODUCTS, FirmwareBundle, @@ -32,7 +32,7 @@ def _delimited(field: int, payload: bytes) -> bytes: return _varint(field << 3 | 2) + _varint(len(payload)) + payload -def _firmware_info_bytes(info: _benshikj.FirmwareInfo) -> bytes: +def _firmware_info_bytes(info: _rpc.FirmwareInfo) -> bytes: return ( _varint(1 << 3) + _varint(info.version) + _delimited(2, info.url.encode()) @@ -42,35 +42,35 @@ def _firmware_info_bytes(info: _benshikj.FirmwareInfo) -> bytes: def test_encode_check_request(): # Field numbers are the wire contract: product_id is field 1, varint. - assert _benshikj.encode_check_request(259) == b"\x08\x83\x02" - assert _benshikj.encode_check_request(259, 147) == b"\x08\x83\x02\x10\x93\x01" + assert _rpc.encode_check_request(259) == b"\x08\x83\x02" + assert _rpc.encode_check_request(259, 147) == b"\x08\x83\x02\x10\x93\x01" # proto3 omits zero-valued fields - assert _benshikj.encode_check_request(0) == b"" + assert _rpc.encode_check_request(0) == b"" def test_encode_decode_roundtrip(): - info = _benshikj.FirmwareInfo(147, "https://example.invalid/p.bin", "abc") + info = _rpc.FirmwareInfo(147, "https://example.invalid/p.bin", "abc") encoded = _delimited(1, _firmware_info_bytes(info)) - decoded = _benshikj.decode_check_result(encoded) + decoded = _rpc.decode_check_result(encoded) assert decoded.firmware == info - assert decoded.base == _benshikj.FirmwareInfo() + assert decoded.base == _rpc.FirmwareInfo() def test_decode_stops_on_unknown_wire_type(): # tag with wire type 7 (invalid); the walk must not loop or raise - assert _benshikj.decode_check_result(b"\x0f\x01\x02") == ( - _benshikj.CheckFirmwareUpdateResult() + assert _rpc.decode_check_result(b"\x0f\x01\x02") == ( + _rpc.CheckFirmwareUpdateResult() ) def test_update_info_from_protocol(): - result = _benshikj.CheckFirmwareUpdateResult( - firmware=_benshikj.FirmwareInfo( + result = _rpc.CheckFirmwareUpdateResult( + firmware=_rpc.FirmwareInfo( version=147, url="https://example.invalid/patch.bin", md5="0c0d095da50bebe664822adcb244834a", ), - base=_benshikj.FirmwareInfo( + base=_rpc.FirmwareInfo( url="https://example.invalid/base.zip", md5="74b6d097d8d2d9d2d9fac88133198a08", ), @@ -91,7 +91,7 @@ def test_update_info_from_protocol(): def test_update_info_from_protocol_empty_means_no_update(): - empty = _benshikj.CheckFirmwareUpdateResult() + empty = _rpc.CheckFirmwareUpdateResult() assert UpdateInfo.from_protocol(empty) is None From 9871875d5fc81551bf014d07b768544ef8b18910 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:41:48 -0700 Subject: [PATCH 48/85] pin that an early reply is buffered, not dropped --- tests/test_flash.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/test_flash.py b/tests/test_flash.py index 6b75889..1a0af59 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -54,6 +54,7 @@ def __init__( chunk: int = CHUNK, skip_first: int = 0, error_after: int | None = None, + preempt_sync: bool = False, ): self.state = state self.chunk = chunk @@ -63,6 +64,7 @@ def __init__( self.sent: t.List[VmControlType] = [] self.final_flags: t.List[bool] = [] self.error_on_finalize = False + self.preempt_sync = preempt_sync self.disconnected = False self.aborted = False self._callback: t.Any = None @@ -120,6 +122,14 @@ def _request_bytes(self) -> None: ), ) + def _emit_sync_cfm(self, md5_tail: bytes) -> None: + self._emit_vmu( + VmuPacketType.UPDATE_SYNC_CFM, + VmControlUpdateSyncCfm( + update_state=self.state, md5sum_tail=md5_tail, unknown=b"\x00" + ), + ) + def _handle(self, msg: p.Message) -> None: if msg.command == p.ExtendedCommand.VM_CONNECT: self._emit( @@ -127,6 +137,9 @@ def _handle(self, msg: p.Message) -> None: VmConnectReplyBody(status=p.ReplyStatus.SUCCESS), is_reply=True, ) + if self.preempt_sync: + # Answers a question that has not been asked yet. + self._emit_sync_cfm(b"\x00\x00\x00\x00") return if msg.command == p.ExtendedCommand.VM_DISCONNECT: @@ -146,14 +159,8 @@ def _handle(self, msg: p.Message) -> None: match body.vm_control_type: case VmControlType.UPDATE_SYNC_REQ: - self._emit_vmu( - VmuPacketType.UPDATE_SYNC_CFM, - VmControlUpdateSyncCfm( - update_state=self.state, - md5sum_tail=body.msg.md5sum_tail, - unknown=b"\x00", - ), - ) + if not self.preempt_sync: + self._emit_sync_cfm(body.msg.md5sum_tail) case VmControlType.UPDATE_START_REQ: self._emit_vmu( VmuPacketType.UPDATE_START_CFM, @@ -309,3 +316,12 @@ def test_failure_after_staging_does_not_abort(): _run(radio, _bundle(b"unused")) assert not radio.aborted + + +def test_reply_arriving_before_it_is_awaited_is_not_lost(): + """The subscription is opened before the first send and held for the whole + flash, so a radio that answers early is buffered rather than dropped.""" + radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE, preempt_sync=True) + + assert _run(radio, _bundle(b"unused")) is FlashResult.REBOOT_PENDING + assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES From c939ccb6f8e6404880d34afe0150be2863058930 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:43:08 -0700 Subject: [PATCH 49/85] narrow message unions in the flash tests for pyright --- tests/test_flash.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_flash.py b/tests/test_flash.py index 1a0af59..6231f3e 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -16,10 +16,13 @@ VmConnectReplyBody, VmControlReplyBody, VmControlType, + VmControlUpdateData, VmControlUpdateDataBytesReq, VmControlUpdateError, VmControlUpdateStartCfm, VmControlUpdateSyncCfm, + VmControlUpdateSyncReq, + VmControlUpdateTransferCompleteRes, VmuPacket, VmuPacketType, UpdateStartCfmCode, @@ -159,6 +162,7 @@ def _handle(self, msg: p.Message) -> None: match body.vm_control_type: case VmControlType.UPDATE_SYNC_REQ: + assert isinstance(body.msg, VmControlUpdateSyncReq) if not self.preempt_sync: self._emit_sync_cfm(body.msg.md5sum_tail) case VmControlType.UPDATE_START_REQ: @@ -171,6 +175,7 @@ def _handle(self, msg: p.Message) -> None: case VmControlType.UPDATE_START_DATA_REQ: self._request_bytes() case VmControlType.UPDATE_DATA: + assert isinstance(body.msg, VmControlUpdateData) self.received += body.msg.data self.final_flags.append(body.msg.is_final_fragment) if self.error_after is not None and \ @@ -295,6 +300,7 @@ async def record(msg: p.Message) -> None: final = sent[-1] assert isinstance(final.body, VmControlBody) + assert isinstance(final.body.msg, VmControlUpdateTransferCompleteRes) assert final.body.msg.is_complete is False From 6af430b780d0f429dccab0f42625fc9b9bc9bba2 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 16:53:33 -0700 Subject: [PATCH 50/85] fix strict-mode typing in flash and its tests --- src/benlink/command.py | 2 +- tests/test_flash.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/benlink/command.py b/src/benlink/command.py index f0fb547..e641458 100644 --- a/src/benlink/command.py +++ b/src/benlink/command.py @@ -95,7 +95,7 @@ async def send_protocol_message(self, msg: p.Message) -> None: async def subscribe( self, match: t.Callable[[RadioMessage], bool] | None = None, - ) -> t.AsyncIterator[asyncio.Queue[RadioMessage]]: + ) -> t.AsyncGenerator[asyncio.Queue[RadioMessage], None]: """Collect matching messages into a queue while the context is held. Enter this before sending whatever provokes the replies, so that a reply diff --git a/tests/test_flash.py b/tests/test_flash.py index 6231f3e..f93ab16 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -205,6 +205,13 @@ def _handle(self, msg: p.Message) -> None: ) case VmControlType.UPDATE_ABORT_REQ: self.aborted = True + case VmControlType.UPDATE_TRANSFER_COMPLETE_RES: + # Acked like every control message; the reboot is the answer. + pass + case _: + raise AssertionError( + f"flash sent an unexpected {body.vm_control_type.name}" + ) def _run(radio: FakeRadio, bundle: FirmwareBundle, **kwargs: t.Any) -> FlashResult: @@ -248,7 +255,11 @@ def test_image_shorter_than_one_chunk(): def test_progress_reports_reach_the_total(): data = b"y" * (CHUNK * 2 + 7) seen: t.List[t.Tuple[str, int, int]] = [] - _run(FakeRadio(), _bundle(data), progress=lambda *a: seen.append(a)) + + def record(label: str, done: int, total: int) -> None: + seen.append((label, done, total)) + + _run(FakeRadio(), _bundle(data), progress=record) assert [n for _, n, _ in seen] == [CHUNK, CHUNK * 2, len(data)] assert all(total == len(data) for _, _, total in seen) From b5a4ac96e96a19103c0f447a30805f1b32cb22f0 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:08:57 -0700 Subject: [PATCH 51/85] align the flashing warnings --- src/benlink/firmware/_flash.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index efd42d0..2f04f4f 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -1,5 +1,8 @@ """Delivering an assembled firmware image to a radio. +**This can break your radio, and nothing in this library can undo it. +Use this at your own risk. I am not responsible for bricking your radio.** + The transfer runs over the same command connection as everything else, in two phases separated by a reboot: @@ -99,7 +102,8 @@ async def flash( ) -> FlashResult: """Deliver an assembled firmware image to a connected radio. - **This can break your radio, and nothing here can undo it.** + **This can break your radio, and nothing in this library can undo it. + Use this at your own risk. I am not responsible for bricking your radio.** Runs whichever phase of the update the radio says it is in, so a full update is two calls with a reconnect in between: From 45dbf3ebc3622e45c6e897612e4fb2866b1ff6fc Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:11:25 -0700 Subject: [PATCH 52/85] make FlashResult a literal union and FlashError a RuntimeError --- src/benlink/firmware/__main__.py | 5 ++--- src/benlink/firmware/_flash.py | 24 +++++++++++------------- tests/test_flash.py | 10 +++++----- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 885796c..04d13c5 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -21,7 +21,6 @@ PRODUCTS, FirmwareBundle, FirmwareInfo, - FlashResult, UpdateInfo, assemble, check_update, @@ -315,7 +314,7 @@ async def _cmd_update(args: argparse.Namespace) -> int: return 1 _out() - if result is FlashResult.COMPLETE: + if result == "COMPLETE": _out("Firmware update complete.") return 0 @@ -336,7 +335,7 @@ async def _commit_after_reboot( await asyncio.sleep(_REBOOT_WAIT) try: async with _radio(args) as conn: - if await flash(conn, bundle) is FlashResult.COMPLETE: + if await flash(conn, bundle) == "COMPLETE": _out() _out("Firmware update complete.") return 0 diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 2f04f4f..9c5d3c3 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -34,7 +34,6 @@ from __future__ import annotations import asyncio import typing as t -from enum import Enum from .. import protocol as p from ..protocol.command.bt_notification import ( @@ -84,15 +83,14 @@ _REBOOT_NOW = False -class FlashResult(Enum): - """What `flash` left the radio doing.""" +FlashResult = t.Literal["REBOOT_PENDING", "COMPLETE"] +"""What `flash` left the radio doing. - REBOOT_PENDING = "reboot_pending" - """The image is staged. The radio is rebooting and the connection will drop; - reconnect and call `flash` again to finish.""" +`REBOOT_PENDING`: the image is staged, and the radio is rebooting. The connection +will drop; reconnect and call `flash` again to finish. - COMPLETE = "complete" - """The update is committed and running.""" +`COMPLETE`: the update is committed and running. +""" async def flash( @@ -108,7 +106,7 @@ async def flash( Runs whichever phase of the update the radio says it is in, so a full update is two calls with a reconnect in between: - if await flash(conn, bundle) is FlashResult.REBOOT_PENDING: + if await flash(conn, bundle) == "REBOOT_PENDING": ... reconnect ... await flash(conn, bundle) @@ -136,15 +134,15 @@ async def flash( await _abort(conn) raise await _request_reboot(conn) - return FlashResult.REBOOT_PENDING + return "REBOOT_PENDING" case UpdateState.TRANSFER_COMPLETE: await _request_reboot(conn) - return FlashResult.REBOOT_PENDING + return "REBOOT_PENDING" case UpdateState.IN_PROGRESS: await _finalize(conn, inbox) - return FlashResult.COMPLETE + return "COMPLETE" case UpdateState.COMMIT: # UPDATE_COMMIT_CFM exists, but no capture shows the app in this @@ -301,7 +299,7 @@ async def _abort(conn: CommandConnection) -> None: ##################### # Transport -class FlashError(Exception): +class FlashError(RuntimeError): """The radio rejected or abandoned the update.""" diff --git a/tests/test_flash.py b/tests/test_flash.py index f93ab16..25e380b 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -229,7 +229,7 @@ def test_transfer_phase_sends_whole_image(): result = _run(radio, _bundle(data)) - assert result is FlashResult.REBOOT_PENDING + assert result == "REBOOT_PENDING" assert bytes(radio.received) == data assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES @@ -248,7 +248,7 @@ def test_image_shorter_than_one_chunk(): data = b"tiny" radio = FakeRadio() - assert _run(radio, _bundle(data)) is FlashResult.REBOOT_PENDING + assert _run(radio, _bundle(data)) == "REBOOT_PENDING" assert bytes(radio.received) == data @@ -280,7 +280,7 @@ def test_in_progress_state_finalizes_instead_of_transferring(): result = _run(radio, _bundle(b"unused")) - assert result is FlashResult.COMPLETE + assert result == "COMPLETE" assert VmControlType.UPDATE_DATA not in radio.sent assert VmControlType.UPDATE_IN_PROGRESS_RES in radio.sent assert radio.disconnected @@ -291,7 +291,7 @@ def test_transfer_complete_state_only_asks_for_the_reboot(): result = _run(radio, _bundle(b"unused")) - assert result is FlashResult.REBOOT_PENDING + assert result == "REBOOT_PENDING" assert VmControlType.UPDATE_DATA not in radio.sent assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES @@ -340,5 +340,5 @@ def test_reply_arriving_before_it_is_awaited_is_not_lost(): flash, so a radio that answers early is buffered rather than dropped.""" radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE, preempt_sync=True) - assert _run(radio, _bundle(b"unused")) is FlashResult.REBOOT_PENDING + assert _run(radio, _bundle(b"unused")) == "REBOOT_PENDING" assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES From 0120d03d39990f06b21736083a358770371aa2c0 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:30:30 -0700 Subject: [PATCH 53/85] flash takes image bytes; add a flash subcommand for local images --- src/benlink/firmware/__main__.py | 51 ++++++++++++++++++++++++++++---- src/benlink/firmware/_flash.py | 30 +++++++++++-------- tests/test_flash.py | 38 +++++++++--------------- 3 files changed, 78 insertions(+), 41 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 04d13c5..8f8262e 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -19,7 +19,6 @@ from . import ( BASE_IMAGES, PRODUCTS, - FirmwareBundle, FirmwareInfo, UpdateInfo, assemble, @@ -306,7 +305,7 @@ async def _cmd_update(args: argparse.Namespace) -> int: _out("Do not power off the radio until this finishes.") try: - result = await flash(conn, bundle, _make_progress()) + result = await flash(conn, bundle.data, _make_progress()) except Exception as e: _out() _out(f"error: {e}") @@ -319,11 +318,11 @@ async def _cmd_update(args: argparse.Namespace) -> int: return 0 _out(" image staged, radio is rebooting") - return await _commit_after_reboot(args, bundle, path) + return await _commit_after_reboot(args, bundle.data, path) async def _commit_after_reboot( - args: argparse.Namespace, bundle: FirmwareBundle, path: str + args: argparse.Namespace, image: bytes, path: str ) -> int: """Reconnect to the rebooted radio and finish the update. @@ -335,7 +334,7 @@ async def _commit_after_reboot( await asyncio.sleep(_REBOOT_WAIT) try: async with _radio(args) as conn: - if await flash(conn, bundle) == "COMPLETE": + if await flash(conn, image) == "COMPLETE": _out() _out("Firmware update complete.") return 0 @@ -349,6 +348,37 @@ async def _commit_after_reboot( return 1 +async def _cmd_flash(args: argparse.Namespace) -> int: + with open(args.image, "rb") as f: + image = f.read() + + _out(f" image {args.image} ({len(image)} bytes)") + _print_verdict(image, args.expect_md5, "--expect-md5") + + async with _radio(args) as conn: + _print_device_info(await conn.get_device_info()) + + _out() + if not _confirm("Flash this image to the radio?", False, args.yes): + return 0 + + _out("Do not power off the radio until this finishes.") + try: + result = await flash(conn, image, _make_progress()) + except Exception as e: + _out() + _out(f"error: {e}") + return 1 + _out() + + if result == "COMPLETE": + _out("Firmware update complete.") + return 0 + + _out(" image staged, radio is rebooting") + return await _commit_after_reboot(args, image, args.image) + + ##################### # Parser @@ -389,6 +419,17 @@ def _parser() -> argparse.ArgumentParser: update.add_argument("--force", action="store_true") update.set_defaults(run=_cmd_update) + flash_cmd = subparsers.add_parser( + "flash", help="flash an already-assembled image to a radio") + _add_radio_args(flash_cmd) + flash_cmd.add_argument("--image", required=True, + help="assembled firmware image to flash") + flash_cmd.add_argument("--expect-md5", metavar="MD5", + help="verify the image against a known md5 first") + flash_cmd.add_argument("--yes", "-y", action="store_true", + help="accept all prompts") + flash_cmd.set_defaults(run=_cmd_flash) + info = subparsers.add_parser( "info", help="read product id and versions from a radio") _add_radio_args(info) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 9c5d3c3..5fc3284 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -33,6 +33,7 @@ from __future__ import annotations import asyncio +import hashlib import typing as t from .. import protocol as p @@ -63,7 +64,7 @@ VmuPacketMessage, VmuPacketType, ) -from ._fetch import FirmwareBundle, ProgressCallback +from ._fetch import ProgressCallback if t.TYPE_CHECKING: from ..command import ( @@ -95,7 +96,7 @@ async def flash( conn: CommandConnection, - bundle: FirmwareBundle, + image: bytes, progress: ProgressCallback | None = None, ) -> FlashResult: """Deliver an assembled firmware image to a connected radio. @@ -106,17 +107,17 @@ async def flash( Runs whichever phase of the update the radio says it is in, so a full update is two calls with a reconnect in between: - if await flash(conn, bundle) == "REBOOT_PENDING": + if await flash(conn, image) == "REBOOT_PENDING": ... reconnect ... - await flash(conn, bundle) + await flash(conn, image) - Passing a `bundle` other than the one already staged is rejected by the radio - at `UPDATE_SYNC_REQ`. + Passing an `image` other than the one already staged is rejected by the radio + at `UPDATE_SYNC_REQ`, which compares the last four bytes of its md5. """ async with conn.subscribe(_is_vm_message) as inbox: await _vm_connect(conn, inbox) - state = (await _sync(conn, inbox, bundle.md5_tail)).update_state + state = (await _sync(conn, inbox, _md5_tail(image))).update_state await _start(conn, inbox) match state: @@ -125,7 +126,7 @@ async def flash( # radio owns it, and UPDATE_ABORT_REQ would throw it away. try: if state is UpdateState.DATA_TRANSFER: - await _transfer(conn, inbox, bundle, progress) + await _transfer(conn, inbox, image, progress) # VALIDATION appears in no capture. The image is already # delivered in that state, so ask whether the checksum # finished rather than sending all of it again. @@ -160,7 +161,7 @@ async def flash( async def _transfer( conn: CommandConnection, inbox: asyncio.Queue[RadioMessage], - bundle: FirmwareBundle, + image: bytes, progress: ProgressCallback | None, ) -> None: """Send the image, one device-requested chunk at a time.""" @@ -168,8 +169,7 @@ async def _transfer( conn, VmControlType.UPDATE_START_DATA_REQ, VmControlUpdateDataStartReq() ) - data = bundle.data - total = len(data) + total = len(image) offset = 0 while offset < total: @@ -183,7 +183,7 @@ async def _transfer( # Non-zero only when the radio already holds part of the image. offset += req.n_bytes_skip - chunk = data[offset:offset + req.n_bytes_requested] + chunk = image[offset:offset + req.n_bytes_requested] if not chunk: raise FlashError( f"radio asked for {req.n_bytes_requested} bytes at offset " @@ -299,6 +299,12 @@ async def _abort(conn: CommandConnection) -> None: ##################### # Transport +def _md5_tail(image: bytes) -> bytes: + """Last 4 bytes of the md5 digest, which is how UPDATE_SYNC_REQ names an + image.""" + return hashlib.md5(image).digest()[-4:] + + class FlashError(RuntimeError): """The radio rejected or abandoned the update.""" diff --git a/tests/test_flash.py b/tests/test_flash.py index 25e380b..08cce42 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -4,7 +4,7 @@ import pytest from benlink.command import CommandConnection -from benlink.firmware import FirmwareBundle, FirmwareInfo, UpdateInfo, flash +from benlink.firmware import flash from benlink.firmware._flash import FlashError, FlashResult import benlink.protocol as p from benlink.protocol.command.bt_notification import ( @@ -34,16 +34,6 @@ CHUNK = 145 -def _bundle(data: bytes) -> FirmwareBundle: - return FirmwareBundle( - data=data, - update_info=UpdateInfo( - firmware=FirmwareInfo(version=147, url="", md5=None), - base=FirmwareInfo(version=1, url="", md5=None), - ), - ) - - class FakeRadio: """A radio that answers the update messages the way the captures do. @@ -214,11 +204,11 @@ def _handle(self, msg: p.Message) -> None: ) -def _run(radio: FakeRadio, bundle: FirmwareBundle, **kwargs: t.Any) -> FlashResult: +def _run(radio: FakeRadio, image: bytes, **kwargs: t.Any) -> FlashResult: async def main() -> FlashResult: conn = CommandConnection(radio) await conn.connect() - return await flash(conn, bundle, **kwargs) + return await flash(conn, image, **kwargs) return asyncio.run(main()) @@ -227,7 +217,7 @@ def test_transfer_phase_sends_whole_image(): data = bytes(range(256)) * 5 radio = FakeRadio() - result = _run(radio, _bundle(data)) + result = _run(radio, data) assert result == "REBOOT_PENDING" assert bytes(radio.received) == data @@ -237,7 +227,7 @@ def test_transfer_phase_sends_whole_image(): def test_final_fragment_is_flagged_once_at_the_end(): data = b"x" * (CHUNK * 3) radio = FakeRadio() - _run(radio, _bundle(data)) + _run(radio, data) # The radio stops asking for more only because the last UPDATE_DATA said so, # and an image that divides evenly into chunks must still flag its last one. @@ -248,7 +238,7 @@ def test_image_shorter_than_one_chunk(): data = b"tiny" radio = FakeRadio() - assert _run(radio, _bundle(data)) == "REBOOT_PENDING" + assert _run(radio, data) == "REBOOT_PENDING" assert bytes(radio.received) == data @@ -259,7 +249,7 @@ def test_progress_reports_reach_the_total(): def record(label: str, done: int, total: int) -> None: seen.append((label, done, total)) - _run(FakeRadio(), _bundle(data), progress=record) + _run(FakeRadio(), data, progress=record) assert [n for _, n, _ in seen] == [CHUNK, CHUNK * 2, len(data)] assert all(total == len(data) for _, _, total in seen) @@ -269,7 +259,7 @@ def test_resume_honours_n_bytes_skip(): data = bytes(range(256)) * 4 radio = FakeRadio(skip_first=300) - _run(radio, _bundle(data)) + _run(radio, data) # The radio already had the first 300 bytes, so they are never resent. assert bytes(radio.received) == data[300:] @@ -278,7 +268,7 @@ def test_resume_honours_n_bytes_skip(): def test_in_progress_state_finalizes_instead_of_transferring(): radio = FakeRadio(state=UpdateState.IN_PROGRESS) - result = _run(radio, _bundle(b"unused")) + result = _run(radio, b"unused") assert result == "COMPLETE" assert VmControlType.UPDATE_DATA not in radio.sent @@ -289,7 +279,7 @@ def test_in_progress_state_finalizes_instead_of_transferring(): def test_transfer_complete_state_only_asks_for_the_reboot(): radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE) - result = _run(radio, _bundle(b"unused")) + result = _run(radio, b"unused") assert result == "REBOOT_PENDING" assert VmControlType.UPDATE_DATA not in radio.sent @@ -307,7 +297,7 @@ async def record(msg: p.Message) -> None: await original(msg) radio.send = record # type: ignore[method-assign] - _run(radio, _bundle(b"unused")) + _run(radio, b"unused") final = sent[-1] assert isinstance(final.body, VmControlBody) @@ -319,7 +309,7 @@ def test_update_error_is_raised_not_waited_out(): radio = FakeRadio(error_after=CHUNK) with pytest.raises(FlashError, match="BATTERY_LOW"): - _run(radio, _bundle(b"z" * CHUNK * 10)) + _run(radio, b"z" * CHUNK * 10) assert radio.aborted @@ -330,7 +320,7 @@ def test_failure_after_staging_does_not_abort(): radio.error_on_finalize = True with pytest.raises(FlashError): - _run(radio, _bundle(b"unused")) + _run(radio, b"unused") assert not radio.aborted @@ -340,5 +330,5 @@ def test_reply_arriving_before_it_is_awaited_is_not_lost(): flash, so a radio that answers early is buffered rather than dropped.""" radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE, preempt_sync=True) - assert _run(radio, _bundle(b"unused")) == "REBOOT_PENDING" + assert _run(radio, b"unused") == "REBOOT_PENDING" assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES From f47f517dc1b876b2be4d2a57315f54c92046c990 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:32:05 -0700 Subject: [PATCH 54/85] say plainly what a radio that ignores the reboot request does --- src/benlink/firmware/_flash.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 5fc3284..5ad5f42 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -225,8 +225,9 @@ async def _request_reboot(conn: CommandConnection) -> None: """Release the staged image, which the radio reboots into on its own. Confirmed on both radios in the captures, the UV-Pro (260) and the GA-5WB - (259). One that stays put instead reports `TRANSFER_COMPLETE` and lands back - here. + (259). A radio that ignores this stays in `TRANSFER_COMPLETE`, and that state + dispatches straight back here, so calling `flash` again re-sends the same + message rather than making progress. """ await _send_control( conn, From c7cb85dff70d31a79132bbabab949914245844d4 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:34:11 -0700 Subject: [PATCH 55/85] name which models the reboot behaviour is confirmed on --- src/benlink/firmware/_flash.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 5ad5f42..29c7195 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -224,10 +224,12 @@ async def _validate( async def _request_reboot(conn: CommandConnection) -> None: """Release the staged image, which the radio reboots into on its own. - Confirmed on both radios in the captures, the UV-Pro (260) and the GA-5WB - (259). A radio that ignores this stays in `TRANSFER_COMPLETE`, and that state + A radio that ignores this stays in `TRANSFER_COMPLETE`, and that state dispatches straight back here, so calling `flash` again re-sends the same message rather than making progress. + + Confirmed on the UV-Pro (260) and the GA-5WB (259), which takes the same + image as the VR-N76. Other models are untested. """ await _send_control( conn, From 6dd0b979eade00b7145e4870bbbb45d084446cba Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:34:43 -0700 Subject: [PATCH 56/85] drop the btsnoop path from the reboot-byte comment --- src/benlink/firmware/_flash.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 29c7195..40c6a50 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -78,8 +78,8 @@ # The radio reboots on its own once it accepts UPDATE_TRANSFER_COMPLETE_RES, so # this byte is not "did the transfer succeed" despite the field name: 0 proceeds -# with the reboot, 1 postpones it. All four successful updates in btsnoop/ send -# 0; the one capture sending 1 is the app's "cancel the restart" button, after +# with the reboot, 1 postpones it. All four successful updates in the captures +# send 0; the one sending 1 is the app's "cancel the restart" button, after # which the radio sits in TRANSFER_COMPLETE until a later session sends 0. _REBOOT_NOW = False From 73602f8544ab4a9e5d3e7048ab709f495bc99e69 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:36:01 -0700 Subject: [PATCH 57/85] attribute the reboot-byte evidence to the btsnoop captures --- src/benlink/firmware/_flash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 40c6a50..003e414 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -78,9 +78,9 @@ # The radio reboots on its own once it accepts UPDATE_TRANSFER_COMPLETE_RES, so # this byte is not "did the transfer succeed" despite the field name: 0 proceeds -# with the reboot, 1 postpones it. All four successful updates in the captures -# send 0; the one sending 1 is the app's "cancel the restart" button, after -# which the radio sits in TRANSFER_COMPLETE until a later session sends 0. +# with the reboot, 1 postpones it. All successful updates in my btsnoop +# captures send 0; the one sending 1 is the app's "cancel the restart" button, +# after which the radio sits in TRANSFER_COMPLETE until a later session sends 0. _REBOOT_NOW = False From 24eecd5b85c1fc9f074c40fbcacda0a918c02b15 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:47:30 -0700 Subject: [PATCH 58/85] abort the transfer on ctrl+c too --- src/benlink/firmware/_flash.py | 10 ++++++++-- tests/test_flash.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 003e414..87c4efa 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -131,7 +131,9 @@ async def flash( # delivered in that state, so ask whether the checksum # finished rather than sending all of it again. await _validate(conn, inbox) - except Exception: + except (Exception, KeyboardInterrupt, asyncio.CancelledError): + # Ctrl+C and cancellation are not `Exception`, but a radio + # left mid-transfer still deserves to be told. await _abort(conn) raise await _request_reboot(conn) @@ -290,7 +292,11 @@ async def _start( async def _abort(conn: CommandConnection) -> None: - """Best effort: the original failure is what the caller needs to see.""" + """Best effort: the original failure is what the caller needs to see. + + Only `Exception` is swallowed, so a second Ctrl+C during the abort gets out + rather than being absorbed by the cleanup. + """ try: await _send_control( conn, VmControlType.UPDATE_ABORT_REQ, VmControlUpdateAbortReq() diff --git a/tests/test_flash.py b/tests/test_flash.py index 08cce42..05b6402 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -48,6 +48,8 @@ def __init__( skip_first: int = 0, error_after: int | None = None, preempt_sync: bool = False, + interrupt_after: int | None = None, + interrupt_abort_too: bool = False, ): self.state = state self.chunk = chunk @@ -58,6 +60,8 @@ def __init__( self.final_flags: t.List[bool] = [] self.error_on_finalize = False self.preempt_sync = preempt_sync + self.interrupt_after = interrupt_after + self.interrupt_abort_too = interrupt_abort_too self.disconnected = False self.aborted = False self._callback: t.Any = None @@ -78,6 +82,13 @@ async def send_bytes(self, data: bytes) -> None: raise AssertionError("flash should not use send_bytes") async def send(self, msg: p.Message) -> None: + if (self.interrupt_after is not None + and self.sent.count(VmControlType.UPDATE_DATA) + >= self.interrupt_after): + if not self.interrupt_abort_too: + # One interrupt only, so the abort that follows can land. + self.interrupt_after = None + raise KeyboardInterrupt self._handle(p.Message.from_bytes(msg.to_bytes())) # Radio behaviour @@ -332,3 +343,23 @@ def test_reply_arriving_before_it_is_awaited_is_not_lost(): assert _run(radio, b"unused") == "REBOOT_PENDING" assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES + + +def test_interrupt_mid_transfer_still_tells_the_radio(): + """Ctrl+C is not an `Exception`, but the radio is left mid-transfer.""" + radio = FakeRadio(interrupt_after=3) + + with pytest.raises(KeyboardInterrupt): + _run(radio, b"x" * CHUNK * 100) + + assert radio.aborted + assert radio.sent.count(VmControlType.UPDATE_DATA) == 3 + + +def test_second_interrupt_is_not_swallowed_by_the_abort(): + radio = FakeRadio(interrupt_after=3, interrupt_abort_too=True) + + with pytest.raises(KeyboardInterrupt): + _run(radio, b"x" * CHUNK * 100) + + assert not radio.aborted From 8cc992383b108df9bde8d06909d2b9ff7bc273db Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:53:42 -0700 Subject: [PATCH 59/85] record that an interrupted transfer restarts rather than resumes --- src/benlink/firmware/_flash.py | 13 ++++++++----- src/benlink/protocol/command/vm.py | 6 ++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 87c4efa..5928fca 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -25,10 +25,10 @@ `flash` runs one phase per call and reports whether a reboot is pending, so the caller owns the reconnect. Which phase runs is decided by the `UpdateState` the -radio reports in `UPDATE_SYNC_CFM` rather than tracked locally, which is also -what makes an interrupted update resumable: a half-finished transfer reports -`DATA_TRANSFER` again and `UPDATE_DATA_BYTES_REQ.n_bytes_skip` says where to -pick up. +radio reports in `UPDATE_SYNC_CFM` rather than tracked locally, so an update +interrupted after the image was staged resumes at the right phase rather than +sending it all again. An interrupted *transfer* is not resumable: the radio +starts it over from the beginning. """ from __future__ import annotations @@ -182,7 +182,10 @@ async def _transfer( timeout=_CHUNK_TIMEOUT, ) - # Non-zero only when the radio already holds part of the image. + # Always 0 in the captures, including across aborted transfers, which + # the radio restarts rather than resumes. Honoured in case some model + # does ask, but the relative reading is a guess with nothing to check + # it against. offset += req.n_bytes_skip chunk = image[offset:offset + req.n_bytes_requested] diff --git a/src/benlink/protocol/command/vm.py b/src/benlink/protocol/command/vm.py index 5ca8fa1..58d44ea 100644 --- a/src/benlink/protocol/command/vm.py +++ b/src/benlink/protocol/command/vm.py @@ -189,8 +189,10 @@ class VmControlUpdateError(Bitfield): class VmControlUpdateDataBytesReq(Bitfield): # The max bytes requested that the HT app allows is 250 n_bytes_requested: int = bf_int(32) - # Skip allows for resuming a firmware update maybe? - # I don't see it used in any of my logs + # Skip would allow resuming a firmware update, but it is 0 in every request + # in my logs, including the ones after an aborted transfer — those restart + # from the beginning instead. So whether it counts from the current position + # or from the start of the image is unknown. n_bytes_skip: int = bf_int(32) From 489d80eeb5645f148a0052235dfe74b4505606de Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 17:57:22 -0700 Subject: [PATCH 60/85] refuse to finish an update for a different image --- src/benlink/firmware/_flash.py | 17 ++++++++++++++--- tests/test_flash.py | 25 +++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 5928fca..835a532 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -111,13 +111,24 @@ async def flash( ... reconnect ... await flash(conn, image) - Passing an `image` other than the one already staged is rejected by the radio - at `UPDATE_SYNC_REQ`, which compares the last four bytes of its md5. + Raises `FlashError` if the radio is partway through a *different* image, so + that finishing an update never commits something the caller didn't pass. """ async with conn.subscribe(_is_vm_message) as inbox: await _vm_connect(conn, inbox) - state = (await _sync(conn, inbox, _md5_tail(image))).update_state + tail = _md5_tail(image) + cfm = await _sync(conn, inbox, tail) + if cfm.md5sum_tail != tail: + # UpdateError.SYNC_IS_DIFFERENT suggests the radio rejects this + # itself, but no capture shows it doing so, and committing the wrong + # image is not something to find out about the hard way. + raise FlashError( + f"radio is partway through a different image: it reports " + f"md5 ...{cfm.md5sum_tail.hex()}, this one is ...{tail.hex()}" + ) + + state = cfm.update_state await _start(conn, inbox) match state: diff --git a/tests/test_flash.py b/tests/test_flash.py index 05b6402..55e7274 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import typing as t import pytest @@ -50,6 +51,7 @@ def __init__( preempt_sync: bool = False, interrupt_after: int | None = None, interrupt_abort_too: bool = False, + staged_tail: bytes | None = None, ): self.state = state self.chunk = chunk @@ -62,6 +64,7 @@ def __init__( self.preempt_sync = preempt_sync self.interrupt_after = interrupt_after self.interrupt_abort_too = interrupt_abort_too + self.staged_tail = staged_tail self.disconnected = False self.aborted = False self._callback: t.Any = None @@ -143,7 +146,8 @@ def _handle(self, msg: p.Message) -> None: ) if self.preempt_sync: # Answers a question that has not been asked yet. - self._emit_sync_cfm(b"\x00\x00\x00\x00") + self._emit_sync_cfm( + self.staged_tail or b"\x00\x00\x00\x00") return if msg.command == p.ExtendedCommand.VM_DISCONNECT: @@ -165,7 +169,8 @@ def _handle(self, msg: p.Message) -> None: case VmControlType.UPDATE_SYNC_REQ: assert isinstance(body.msg, VmControlUpdateSyncReq) if not self.preempt_sync: - self._emit_sync_cfm(body.msg.md5sum_tail) + self._emit_sync_cfm( + self.staged_tail or body.msg.md5sum_tail) case VmControlType.UPDATE_START_REQ: self._emit_vmu( VmuPacketType.UPDATE_START_CFM, @@ -339,9 +344,11 @@ def test_failure_after_staging_does_not_abort(): def test_reply_arriving_before_it_is_awaited_is_not_lost(): """The subscription is opened before the first send and held for the whole flash, so a radio that answers early is buffered rather than dropped.""" - radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE, preempt_sync=True) + image = b"unused" + radio = FakeRadio(state=UpdateState.TRANSFER_COMPLETE, preempt_sync=True, + staged_tail=hashlib.md5(image).digest()[-4:]) - assert _run(radio, b"unused") == "REBOOT_PENDING" + assert _run(radio, image) == "REBOOT_PENDING" assert radio.sent[-1] == VmControlType.UPDATE_TRANSFER_COMPLETE_RES @@ -363,3 +370,13 @@ def test_second_interrupt_is_not_swallowed_by_the_abort(): _run(radio, b"x" * CHUNK * 100) assert not radio.aborted + + +def test_refuses_to_finish_someone_elses_update(): + """A radio holding a different image must not be committed by mistake.""" + radio = FakeRadio(state=UpdateState.IN_PROGRESS, staged_tail=b"\xde\xad\xbe\xef") + + with pytest.raises(FlashError, match="different image"): + _run(radio, b"unused") + + assert VmControlType.UPDATE_IN_PROGRESS_RES not in radio.sent From 0b4f1afeebcaf84d9d3d3e2a4f3b81eb12eaabb5 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:04:15 -0700 Subject: [PATCH 61/85] add abort_update and flash --abort as the way out of a stranded update --- src/benlink/firmware/__init__.py | 3 ++- src/benlink/firmware/__main__.py | 25 ++++++++++++++++++++++++- src/benlink/firmware/_flash.py | 22 +++++++++++++++++++++- tests/test_flash.py | 22 +++++++++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 5fbd33a..2d23ff2 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -95,7 +95,7 @@ oss_patch_url, oss_update_info, ) -from ._flash import FlashError, FlashResult, flash +from ._flash import FlashError, FlashResult, abort_update, flash __all__ = [ "BASE_IMAGES", @@ -106,6 +106,7 @@ "FlashResult", "ProgressCallback", "UpdateInfo", + "abort_update", "assemble", "check_update", "download", diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 8f8262e..aee08d1 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -21,6 +21,7 @@ PRODUCTS, FirmwareInfo, UpdateInfo, + abort_update, assemble, check_update, download, @@ -348,7 +349,27 @@ async def _commit_after_reboot( return 1 +async def _cmd_abort(args: argparse.Namespace) -> int: + _out("This discards whatever update the radio has in progress.") + if not _confirm("Abort it?", False, args.yes): + return 0 + + async with _radio(args) as conn: + _print_device_info(await conn.get_device_info()) + await abort_update(conn) + + _out() + _out("Aborted. The radio is still running its current firmware.") + return 0 + + async def _cmd_flash(args: argparse.Namespace) -> int: + if args.abort: + return await _cmd_abort(args) + + if not args.image: + raise RuntimeError("--image is required (or --abort to clear the radio)") + with open(args.image, "rb") as f: image = f.read() @@ -422,8 +443,10 @@ def _parser() -> argparse.ArgumentParser: flash_cmd = subparsers.add_parser( "flash", help="flash an already-assembled image to a radio") _add_radio_args(flash_cmd) - flash_cmd.add_argument("--image", required=True, + flash_cmd.add_argument("--image", help="assembled firmware image to flash") + flash_cmd.add_argument("--abort", action="store_true", + help="discard the radio's in-progress update instead") flash_cmd.add_argument("--expect-md5", metavar="MD5", help="verify the image against a known md5 first") flash_cmd.add_argument("--yes", "-y", action="store_true", diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 835a532..9ad7f1d 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -125,7 +125,9 @@ async def flash( # image is not something to find out about the hard way. raise FlashError( f"radio is partway through a different image: it reports " - f"md5 ...{cfm.md5sum_tail.hex()}, this one is ...{tail.hex()}" + f"md5 ...{cfm.md5sum_tail.hex()}, this one is ...{tail.hex()}. " + f"Flash the matching image to finish that update, or call " + f"abort_update to discard it" ) state = cfm.update_state @@ -168,6 +170,24 @@ async def flash( ) +async def abort_update(conn: CommandConnection) -> None: + """Discard whatever update the radio is partway through. + + The way out when an image has been staged but the file that produced it is + gone: `flash` refuses to finish an update it cannot identify, and without + this there would be nothing left to try. + """ + async with conn.subscribe(_is_vm_message) as inbox: + await _vm_connect(conn, inbox) + await _send_control( + conn, VmControlType.UPDATE_ABORT_REQ, VmControlUpdateAbortReq() + ) + await _recv_vmu(inbox, VmuPacketType.UPDATE_ABORT_CFM) + await conn.send_protocol_message( + _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) + ) + + ##################### # Phases diff --git a/tests/test_flash.py b/tests/test_flash.py index 55e7274..9c0fa21 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -5,7 +5,7 @@ import pytest from benlink.command import CommandConnection -from benlink.firmware import flash +from benlink.firmware import abort_update, flash from benlink.firmware._flash import FlashError, FlashResult import benlink.protocol as p from benlink.protocol.command.bt_notification import ( @@ -28,6 +28,7 @@ VmuPacketType, UpdateStartCfmCode, VmControlBody, + VmControlUpdateAbortCfm, VmControlUpdateCompleteInd, VmControlUpdateTransferCompleteInd, ) @@ -211,6 +212,9 @@ def _handle(self, msg: p.Message) -> None: ) case VmControlType.UPDATE_ABORT_REQ: self.aborted = True + self._emit_vmu( + VmuPacketType.UPDATE_ABORT_CFM, VmControlUpdateAbortCfm() + ) case VmControlType.UPDATE_TRANSFER_COMPLETE_RES: # Acked like every control message; the reboot is the answer. pass @@ -380,3 +384,19 @@ def test_refuses_to_finish_someone_elses_update(): _run(radio, b"unused") assert VmControlType.UPDATE_IN_PROGRESS_RES not in radio.sent + + +def test_abort_update_clears_a_stranded_radio(): + """The way out when the image that produced a staged update is gone.""" + radio = FakeRadio(state=UpdateState.IN_PROGRESS) + + async def main() -> None: + conn = CommandConnection(radio) + await conn.connect() + await abort_update(conn) + + asyncio.run(main()) + + assert radio.aborted + assert radio.disconnected + assert VmControlType.UPDATE_IN_PROGRESS_RES not in radio.sent From d6e11f8428b341871bc8c2bec464daf22f03a1a4 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:06:01 -0700 Subject: [PATCH 62/85] make abort its own subcommand --- src/benlink/firmware/__main__.py | 17 ++++++++--------- src/benlink/firmware/_flash.py | 7 +------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index aee08d1..853fa1b 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -364,12 +364,6 @@ async def _cmd_abort(args: argparse.Namespace) -> int: async def _cmd_flash(args: argparse.Namespace) -> int: - if args.abort: - return await _cmd_abort(args) - - if not args.image: - raise RuntimeError("--image is required (or --abort to clear the radio)") - with open(args.image, "rb") as f: image = f.read() @@ -443,16 +437,21 @@ def _parser() -> argparse.ArgumentParser: flash_cmd = subparsers.add_parser( "flash", help="flash an already-assembled image to a radio") _add_radio_args(flash_cmd) - flash_cmd.add_argument("--image", + flash_cmd.add_argument("--image", required=True, help="assembled firmware image to flash") - flash_cmd.add_argument("--abort", action="store_true", - help="discard the radio's in-progress update instead") flash_cmd.add_argument("--expect-md5", metavar="MD5", help="verify the image against a known md5 first") flash_cmd.add_argument("--yes", "-y", action="store_true", help="accept all prompts") flash_cmd.set_defaults(run=_cmd_flash) + abort_cmd = subparsers.add_parser( + "abort-update", help="discard an update the radio is partway through") + _add_radio_args(abort_cmd) + abort_cmd.add_argument("--yes", "-y", action="store_true", + help="accept all prompts") + abort_cmd.set_defaults(run=_cmd_abort) + info = subparsers.add_parser( "info", help="read product id and versions from a radio") _add_radio_args(info) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 9ad7f1d..ea79107 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -171,12 +171,7 @@ async def flash( async def abort_update(conn: CommandConnection) -> None: - """Discard whatever update the radio is partway through. - - The way out when an image has been staged but the file that produced it is - gone: `flash` refuses to finish an update it cannot identify, and without - this there would be nothing left to try. - """ + """Discard whatever update the radio is partway through.""" async with conn.subscribe(_is_vm_message) as inbox: await _vm_connect(conn, inbox) await _send_control( From 3d6de2b0e2ef81d7e51667753bd777cc97ea50a1 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:09:13 -0700 Subject: [PATCH 63/85] add a _vm_disconnect helper to match _vm_connect --- src/benlink/firmware/_flash.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index ea79107..4720ca4 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -178,9 +178,7 @@ async def abort_update(conn: CommandConnection) -> None: conn, VmControlType.UPDATE_ABORT_REQ, VmControlUpdateAbortReq() ) await _recv_vmu(inbox, VmuPacketType.UPDATE_ABORT_CFM) - await conn.send_protocol_message( - _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) - ) + await _vm_disconnect(conn) ##################### @@ -280,9 +278,7 @@ async def _finalize( inbox, VmuPacketType.UPDATE_COMPLETE_IND, timeout=_COMPLETE_TIMEOUT ) - await conn.send_protocol_message( - _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) - ) + await _vm_disconnect(conn) async def _vm_connect( @@ -296,6 +292,14 @@ async def _vm_connect( raise FlashError(f"VM_CONNECT rejected: {reply.status.name}") +async def _vm_disconnect(conn: CommandConnection) -> None: + """The reply is not waited for: by this point the radio may be committing or + rebooting, and there is nothing left to do with the answer either way.""" + await conn.send_protocol_message( + _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) + ) + + async def _sync( conn: CommandConnection, inbox: asyncio.Queue[RadioMessage], md5_tail: bytes ) -> VmControlUpdateSyncCfm: From 0456bb721cf58874d564d14ae0a186e435b1879c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:09:58 -0700 Subject: [PATCH 64/85] rename _abort to _abort_transfer to distinguish it from abort_update --- src/benlink/firmware/_flash.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 4720ca4..8dfda5e 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -147,7 +147,7 @@ async def flash( except (Exception, KeyboardInterrupt, asyncio.CancelledError): # Ctrl+C and cancellation are not `Exception`, but a radio # left mid-transfer still deserves to be told. - await _abort(conn) + await _abort_transfer(conn) raise await _request_reboot(conn) return "REBOOT_PENDING" @@ -324,7 +324,7 @@ async def _start( await _recv_vmu(inbox, VmuPacketType.UPDATE_START_CFM) -async def _abort(conn: CommandConnection) -> None: +async def _abort_transfer(conn: CommandConnection) -> None: """Best effort: the original failure is what the caller needs to see. Only `Exception` is swallowed, so a second Ctrl+C during the abort gets out From 03e38784121a7427fc17d27022c3ec1ce00d2bb3 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:13:20 -0700 Subject: [PATCH 65/85] name the image in the flash confirmation --- src/benlink/firmware/__main__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 853fa1b..eb4f786 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -300,7 +300,7 @@ async def _cmd_update(args: argparse.Namespace) -> int: _write(path, bundle.data, args.force) _out() - if not _confirm("Flash to radio?", False, args.yes): + if not _confirm(f"Flash v{latest} to this radio?", False, args.yes): _out(f"The assembled image has been kept at {path}") return 0 @@ -374,7 +374,8 @@ async def _cmd_flash(args: argparse.Namespace) -> int: _print_device_info(await conn.get_device_info()) _out() - if not _confirm("Flash this image to the radio?", False, args.yes): + question = f"Flash {os.path.basename(args.image)} to this radio?" + if not _confirm(question, False, args.yes): return 0 _out("Do not power off the radio until this finishes.") From 33ceedb5a9fe3499ecdb1497e89ae33cc5bf655a Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:17:31 -0700 Subject: [PATCH 66/85] register for VMU notifications, without which no reply ever arrives --- src/benlink/firmware/_flash.py | 15 +++++++++++++++ tests/test_flash.py | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 8dfda5e..904a10e 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -7,6 +7,7 @@ phases separated by a reboot: VM_CONNECT + REGISTER_BT_NOTIFICATION (VMU_PACKET) UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM (DATA_TRANSFER) UPDATE_START_REQ -> UPDATE_START_CFM UPDATE_START_DATA_REQ @@ -18,9 +19,11 @@ [radio reboots, connection drops, reconnect] VM_CONNECT + REGISTER_BT_NOTIFICATION (VMU_PACKET) UPDATE_SYNC_REQ -> UPDATE_SYNC_CFM (IN_PROGRESS) UPDATE_START_REQ -> UPDATE_START_CFM UPDATE_IN_PROGRESS_RES -> UPDATE_COMPLETE_IND + CANCEL_BT_NOTIFICATION (VMU_PACKET) VM_DISCONNECT `flash` runs one phase per call and reports whether a reboot is pending, so the @@ -291,10 +294,22 @@ async def _vm_connect( if reply.status != p.ReplyStatus.SUCCESS: raise FlashError(f"VM_CONNECT rejected: {reply.status.name}") + # Every reply worth having arrives as a BT_EVENT_NOTIFICATION, and the radio + # sends none until asked. Without this, VM_CONNECT succeeds and then every + # wait for a VMU packet times out. The app does not wait for the reply. + await conn.send_protocol_message(_message( + p.ExtendedCommand.REGISTER_BT_NOTIFICATION, + bytes([BtEventType.VMU_PACKET]), + )) + async def _vm_disconnect(conn: CommandConnection) -> None: """The reply is not waited for: by this point the radio may be committing or rebooting, and there is nothing left to do with the answer either way.""" + await conn.send_protocol_message(_message( + p.ExtendedCommand.CANCEL_BT_NOTIFICATION, + bytes([BtEventType.VMU_PACKET]), + )) await conn.send_protocol_message( _message(p.ExtendedCommand.VM_DISCONNECT, VmDisconnectBody()) ) diff --git a/tests/test_flash.py b/tests/test_flash.py index 9c0fa21..97a2176 100644 --- a/tests/test_flash.py +++ b/tests/test_flash.py @@ -67,6 +67,7 @@ def __init__( self.interrupt_abort_too = interrupt_abort_too self.staged_tail = staged_tail self.disconnected = False + self.vmu_registered = False self.aborted = False self._callback: t.Any = None self._chunks_served = 0 @@ -107,6 +108,9 @@ def _emit(self, command: p.ExtendedCommand, body: t.Any, is_reply: bool) -> None self._callback(p.Message.from_bytes(out.to_bytes())) def _emit_vmu(self, packet_type: VmuPacketType, msg: t.Any) -> None: + if not self.vmu_registered: + # A real radio sends nothing until REGISTER_BT_NOTIFICATION. + return packet = VmuPacket( vmu_packet_type=packet_type, n_bytes_payload=len(msg.to_bytes()), @@ -145,12 +149,21 @@ def _handle(self, msg: p.Message) -> None: VmConnectReplyBody(status=p.ReplyStatus.SUCCESS), is_reply=True, ) + return + + if msg.command == p.ExtendedCommand.REGISTER_BT_NOTIFICATION: + assert msg.body == bytes([BtEventType.VMU_PACKET]) + self.vmu_registered = True if self.preempt_sync: # Answers a question that has not been asked yet. self._emit_sync_cfm( self.staged_tail or b"\x00\x00\x00\x00") return + if msg.command == p.ExtendedCommand.CANCEL_BT_NOTIFICATION: + self.vmu_registered = False + return + if msg.command == p.ExtendedCommand.VM_DISCONNECT: self.disconnected = True return From f4d5304e3881576de0007f3cd26f843a4f1b42ba Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 18:59:25 -0700 Subject: [PATCH 67/85] show size, rate and eta while transferring --- src/benlink/firmware/__main__.py | 48 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index eb4f786..531d786 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -12,6 +12,7 @@ import os import sys import tempfile +import time if t.TYPE_CHECKING: from ..command import CommandConnection, DeviceInfo @@ -45,17 +46,52 @@ def _out(message: str = "") -> None: print(message, file=sys.stderr) +def _size(n: float) -> str: + if n >= 1e6: + return f"{n / 1e6:.1f}MB" + if n >= 1e3: + return f"{n / 1e3:.0f}kB" + return f"{n:.0f}B" + + +def _duration(seconds: float) -> str: + total = int(seconds) + if total >= 3600: + return f"{total // 3600}h{(total % 3600) // 60:02d}m" + return f"{total // 60}:{total % 60:02d}" + + def _make_progress() -> t.Callable[[str, int, int], None]: - """Render concurrent downloads as one updating line.""" + """Render concurrent transfers as one updating line. + + Flashing over BLE runs for many minutes, so a bare percentage is not enough + to tell slow from stuck. + """ + started: t.Dict[str, float] = {} state: t.Dict[str, t.Tuple[int, int]] = {} + width = 0 + + def render(label: str, done: int, total: int) -> str: + if not total: + return f"{label} {_size(done)}" + out = f"{label} {100 * done // total}% of {_size(total)}" + elapsed = time.monotonic() - started[label] + # Averaged rather than instantaneous: steadier, and the eta is what the + # reader is actually after. The first sample lands with barely any time + # on the clock, so hold off until the rate means something. + if elapsed > 0.5 and done: + rate = done / elapsed + out += f" {_size(rate)}/s eta {_duration((total - done) / rate)}" + return out def progress(label: str, done: int, total: int) -> None: + nonlocal width + started.setdefault(label, time.monotonic()) state[label] = (done, total) - line = " ".join( - f"{k} {100 * d // n}%" if n else f"{k} {d}B" - for k, (d, n) in state.items() - ) - print(f"\r {line}", end="", file=sys.stderr, flush=True) + line = " ".join(render(k, d, n) for k, (d, n) in state.items()) + # Pad to the widest line so far, or a shrinking eta leaves debris behind. + width = max(width, len(line)) + print(f"\r {line:<{width}}", end="", file=sys.stderr, flush=True) return progress From 217310d494bc7c631756697d576bc7258e7fbb7f Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:04:24 -0700 Subject: [PATCH 68/85] fix contextmanager annotation --- src/benlink/firmware/__main__.py | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 531d786..7a79637 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -10,6 +10,7 @@ import contextlib import hashlib import os +import signal import sys import tempfile import time @@ -135,6 +136,26 @@ def _write(path: str, data: bytes, force: bool) -> None: print(path) +@contextlib.contextmanager +def _graceful_interrupt() -> t.Generator[None, None, None]: + """Say something the moment Ctrl+C lands. + + `flash` tells the radio to abort on the way out, which takes a moment. With + no message the transfer just appears to hang after the keypress. + """ + def handler(*_: t.Any) -> None: + _out() + _out("Interrupted. Telling the radio to abort " + "(press Ctrl+C again to quit without waiting)...") + raise KeyboardInterrupt + + previous = signal.signal(signal.SIGINT, handler) + try: + yield + finally: + signal.signal(signal.SIGINT, previous) + + def _confirm(question: str, default_yes: bool, assume_yes: bool) -> bool: if assume_yes: return True @@ -342,7 +363,13 @@ async def _cmd_update(args: argparse.Namespace) -> int: _out("Do not power off the radio until this finishes.") try: - result = await flash(conn, bundle.data, _make_progress()) + with _graceful_interrupt(): + result = await flash(conn, bundle.data, _make_progress()) + except KeyboardInterrupt: + _out("Stopped. The radio was told to discard the transfer; " + "re-run to start over, as it does not resume.") + _out(f"The assembled image has been kept at {path}") + return 130 except Exception as e: _out() _out(f"error: {e}") @@ -416,7 +443,12 @@ async def _cmd_flash(args: argparse.Namespace) -> int: _out("Do not power off the radio until this finishes.") try: - result = await flash(conn, image, _make_progress()) + with _graceful_interrupt(): + result = await flash(conn, image, _make_progress()) + except KeyboardInterrupt: + _out("Stopped. The radio was told to discard the transfer; " + "re-run to start over, as it does not resume.") + return 130 except Exception as e: _out() _out(f"error: {e}") From 30bc8aa3da47b9fd54763be5c73fe0ef2f617b1e Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:08:32 -0700 Subject: [PATCH 69/85] compute the transfer rate over a trailing window --- src/benlink/firmware/__main__.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 7a79637..e09d11d 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -7,6 +7,7 @@ import typing as t import argparse import asyncio +import collections import contextlib import hashlib import os @@ -39,6 +40,9 @@ _COMMIT_ATTEMPTS = 5 +_RATE_WINDOW = 30.0 +"""Seconds of history the transfer rate is averaged over.""" + ##################### # Output @@ -68,7 +72,7 @@ def _make_progress() -> t.Callable[[str, int, int], None]: Flashing over BLE runs for many minutes, so a bare percentage is not enough to tell slow from stuck. """ - started: t.Dict[str, float] = {} + recent: t.Dict[str, t.Deque[t.Tuple[float, int]]] = {} state: t.Dict[str, t.Tuple[int, int]] = {} width = 0 @@ -76,18 +80,25 @@ def render(label: str, done: int, total: int) -> str: if not total: return f"{label} {_size(done)}" out = f"{label} {100 * done // total}% of {_size(total)}" - elapsed = time.monotonic() - started[label] - # Averaged rather than instantaneous: steadier, and the eta is what the - # reader is actually after. The first sample lands with barely any time - # on the clock, so hold off until the rate means something. - if elapsed > 0.5 and done: - rate = done / elapsed + # Rate over a trailing window, not since the start. Connection setup and + # BLE's opening connection interval are slow enough that a running + # average reads far below the rate actually being achieved, and the eta + # derived from it is wrong by minutes. + window = recent[label] + elapsed = window[-1][0] - window[0][0] + moved = done - window[0][1] + if elapsed > 1.0 and moved > 0: + rate = moved / elapsed out += f" {_size(rate)}/s eta {_duration((total - done) / rate)}" return out def progress(label: str, done: int, total: int) -> None: nonlocal width - started.setdefault(label, time.monotonic()) + now = time.monotonic() + window = recent.setdefault(label, collections.deque(maxlen=512)) + window.append((now, done)) + while len(window) > 2 and now - window[0][0] > _RATE_WINDOW: + window.popleft() state[label] = (done, total) line = " ".join(render(k, d, n) for k, (d, n) in state.items()) # Pad to the widest line so far, or a shrinking eta leaves debris behind. From 9cb226d7383aac9a03920cc65ee7997189a8c85a Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:12:31 -0700 Subject: [PATCH 70/85] cancel the task on ctrl+c so the abort actually gets sent --- src/benlink/firmware/__main__.py | 43 ++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index e09d11d..9856ecc 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -147,24 +147,41 @@ def _write(path: str, data: bytes, force: bool) -> None: print(path) -@contextlib.contextmanager -def _graceful_interrupt() -> t.Generator[None, None, None]: - """Say something the moment Ctrl+C lands. +@contextlib.asynccontextmanager +async def _graceful_interrupt() -> t.AsyncGenerator[None, None]: + """Turn Ctrl+C into a cancellation of the task doing the work. + + The default handler raises `KeyboardInterrupt` wherever the main thread + happens to be, which during a transfer is almost always inside the event + loop rather than inside the coroutine. `flash` then never sees it, and never + gets to tell the radio to abort. Cancelling the task delivers the interrupt + where the cleanup lives. - `flash` tells the radio to abort on the way out, which takes a moment. With - no message the transfer just appears to hang after the keypress. + Restoring the default handler on the way in means a second Ctrl+C quits + outright rather than waiting for the abort to be sent. """ - def handler(*_: t.Any) -> None: + loop = asyncio.get_running_loop() + task = asyncio.current_task() + + def interrupt() -> None: _out() _out("Interrupted. Telling the radio to abort " "(press Ctrl+C again to quit without waiting)...") - raise KeyboardInterrupt + loop.remove_signal_handler(signal.SIGINT) + if task is not None: + task.cancel() + + try: + loop.add_signal_handler(signal.SIGINT, interrupt) + except NotImplementedError: # not available on Windows + yield + return - previous = signal.signal(signal.SIGINT, handler) try: yield finally: - signal.signal(signal.SIGINT, previous) + with contextlib.suppress(ValueError, RuntimeError): + loop.remove_signal_handler(signal.SIGINT) def _confirm(question: str, default_yes: bool, assume_yes: bool) -> bool: @@ -374,9 +391,9 @@ async def _cmd_update(args: argparse.Namespace) -> int: _out("Do not power off the radio until this finishes.") try: - with _graceful_interrupt(): + async with _graceful_interrupt(): result = await flash(conn, bundle.data, _make_progress()) - except KeyboardInterrupt: + except asyncio.CancelledError: _out("Stopped. The radio was told to discard the transfer; " "re-run to start over, as it does not resume.") _out(f"The assembled image has been kept at {path}") @@ -454,9 +471,9 @@ async def _cmd_flash(args: argparse.Namespace) -> int: _out("Do not power off the radio until this finishes.") try: - with _graceful_interrupt(): + async with _graceful_interrupt(): result = await flash(conn, image, _make_progress()) - except KeyboardInterrupt: + except asyncio.CancelledError: _out("Stopped. The radio was told to discard the transfer; " "re-run to start over, as it does not resume.") return 130 From 0c5148027a5d8e584c0b63d3fd1de0e518b252b3 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:16:38 -0700 Subject: [PATCH 71/85] wait for the radio to confirm the abort before exiting --- src/benlink/firmware/__main__.py | 8 ++++---- src/benlink/firmware/_flash.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 9856ecc..d3a8b56 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -394,8 +394,8 @@ async def _cmd_update(args: argparse.Namespace) -> int: async with _graceful_interrupt(): result = await flash(conn, bundle.data, _make_progress()) except asyncio.CancelledError: - _out("Stopped. The radio was told to discard the transfer; " - "re-run to start over, as it does not resume.") + _out("Cancelled. Re-run to start over, as the transfer " + "does not resume.") _out(f"The assembled image has been kept at {path}") return 130 except Exception as e: @@ -474,8 +474,8 @@ async def _cmd_flash(args: argparse.Namespace) -> int: async with _graceful_interrupt(): result = await flash(conn, image, _make_progress()) except asyncio.CancelledError: - _out("Stopped. The radio was told to discard the transfer; " - "re-run to start over, as it does not resume.") + _out("Cancelled. Re-run to start over, as the transfer " + "does not resume.") return 130 except Exception as e: _out() diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 904a10e..513eea1 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -78,6 +78,7 @@ _CHUNK_TIMEOUT = 60.0 _VALIDATION_TIMEOUT = 180.0 _COMPLETE_TIMEOUT = 180.0 +_ABORT_TIMEOUT = 5.0 # The radio reboots on its own once it accepts UPDATE_TRANSFER_COMPLETE_RES, so # this byte is not "did the transfer succeed" despite the field name: 0 proceeds @@ -150,7 +151,7 @@ async def flash( except (Exception, KeyboardInterrupt, asyncio.CancelledError): # Ctrl+C and cancellation are not `Exception`, but a radio # left mid-transfer still deserves to be told. - await _abort_transfer(conn) + await _abort_transfer(conn, inbox) raise await _request_reboot(conn) return "REBOOT_PENDING" @@ -339,9 +340,15 @@ async def _start( await _recv_vmu(inbox, VmuPacketType.UPDATE_START_CFM) -async def _abort_transfer(conn: CommandConnection) -> None: +async def _abort_transfer( + conn: CommandConnection, inbox: asyncio.Queue[RadioMessage] +) -> None: """Best effort: the original failure is what the caller needs to see. + Waits for `UPDATE_ABORT_CFM` so the radio has actually processed the abort + before the caller drops the link, but on a short leash — this runs while the + caller is already unwinding, often from Ctrl+C, and must not look hung. + Only `Exception` is swallowed, so a second Ctrl+C during the abort gets out rather than being absorbed by the cleanup. """ @@ -349,6 +356,9 @@ async def _abort_transfer(conn: CommandConnection) -> None: await _send_control( conn, VmControlType.UPDATE_ABORT_REQ, VmControlUpdateAbortReq() ) + await _recv_vmu( + inbox, VmuPacketType.UPDATE_ABORT_CFM, timeout=_ABORT_TIMEOUT + ) except Exception: pass From eacf3844123ecdd111d3cf6b78b6b391f3a8d9bb Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:26:12 -0700 Subject: [PATCH 72/85] group the firmware exports by what they are for --- src/benlink/firmware/__init__.py | 35 +++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 2d23ff2..81547bd 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -97,24 +97,35 @@ ) from ._flash import FlashError, FlashResult, abort_update, flash +# Grouped by what you reach for, in the order you reach for it, rather than +# alphabetically: pdoc lays the documentation page out in exactly this order. __all__ = [ - "BASE_IMAGES", + # Which radios and base images exist "PRODUCTS", - "FirmwareBundle", + "BASE_IMAGES", + + # What a release looks like "FirmwareInfo", - "FlashError", - "FlashResult", - "ProgressCallback", "UpdateInfo", - "abort_update", - "assemble", + "FirmwareBundle", + "ProgressCallback", + + # Finding one "check_update", - "download", + "oss_update_info", + "oss_patch_url", + "oss_base_url", + + # Downloading and assembling it + "fetch_firmware", "download_firmware", + "download", "extract_base", - "fetch_firmware", + "assemble", + + # Putting it on the radio "flash", - "oss_base_url", - "oss_patch_url", - "oss_update_info", + "abort_update", + "FlashResult", + "FlashError", ] From 6e11abc322f295c89c0a0f5cb25b0cfd499fbc2c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 19:27:10 -0700 Subject: [PATCH 73/85] say what abort_update is for --- src/benlink/firmware/_flash.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 513eea1..7a1cddf 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -175,7 +175,12 @@ async def flash( async def abort_update(conn: CommandConnection) -> None: - """Discard whatever update the radio is partway through.""" + """Discard whatever update the radio is partway through. + + Clears one left incomplete by a flash that died without aborting, or whose + image is no longer to hand: `flash` will not finish an update for an image it + wasn't given, so without this the radio stays stuck partway. + """ async with conn.subscribe(_is_vm_message) as inbox: await _vm_connect(conn, inbox) await _send_control( From 144bad658c60eec80a4fa589567d02179674cceb Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:24:09 -0700 Subject: [PATCH 74/85] shorten the abort subcommand to match the other cli verbs --- src/benlink/firmware/__init__.py | 8 +++++++- src/benlink/firmware/__main__.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 81547bd..5e7c83b 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -33,7 +33,7 @@ # The pieces Each step is also available alone, for archiving old releases or working away from -the radio. Everything but `info` and `flash` avoids the Bluetooth stack. +the radio. Everything but `info`, `flash` and `abort` avoids the Bluetooth stack. ```bash # which radio is this? @@ -51,6 +51,12 @@ # combine them offline python -m benlink.firmware assemble --base base.zip --patch patch.bin -o fw.bin + +# put an image you already have onto the radio +python -m benlink.firmware flash XX:XX:XX:XX:XX:XX --image fw.bin + +# clear an update the radio was left partway through +python -m benlink.firmware abort XX:XX:XX:XX:XX:XX ``` `--product` is a shorthand for the radios in `PRODUCTS`; `--product-id` works for diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index d3a8b56..6b1042e 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -543,7 +543,7 @@ def _parser() -> argparse.ArgumentParser: flash_cmd.set_defaults(run=_cmd_flash) abort_cmd = subparsers.add_parser( - "abort-update", help="discard an update the radio is partway through") + "abort", help="discard an update the radio is partway through") _add_radio_args(abort_cmd) abort_cmd.add_argument("--yes", "-y", action="store_true", help="accept all prompts") From c4a20bd6c7f812be1fda4f032e7bab878e70537a Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:24:57 -0700 Subject: [PATCH 75/85] note when abort is needed --- src/benlink/firmware/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 5e7c83b..aabd70b 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -55,7 +55,7 @@ # put an image you already have onto the radio python -m benlink.firmware flash XX:XX:XX:XX:XX:XX --image fw.bin -# clear an update the radio was left partway through +# clear an update the radio was left partway through (if you didn't exit cleanly) python -m benlink.firmware abort XX:XX:XX:XX:XX:XX ``` From 3be44c796981fed3dc0c88f900441712049ca602 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:25:29 -0700 Subject: [PATCH 76/85] match the abort_update docs to the cli wording --- src/benlink/firmware/_flash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/_flash.py b/src/benlink/firmware/_flash.py index 7a1cddf..e2b7253 100644 --- a/src/benlink/firmware/_flash.py +++ b/src/benlink/firmware/_flash.py @@ -177,7 +177,7 @@ async def flash( async def abort_update(conn: CommandConnection) -> None: """Discard whatever update the radio is partway through. - Clears one left incomplete by a flash that died without aborting, or whose + For an update left behind by a flash that did not exit cleanly, or whose image is no longer to hand: `flash` will not finish an update for an image it wasn't given, so without this the radio stays stuck partway. """ From bc4765bf2787183c9ce3b7909954e28f03535934 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:26:45 -0700 Subject: [PATCH 77/85] document what ctrl+c does to a transfer --- src/benlink/firmware/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index aabd70b..05fe86d 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -30,6 +30,11 @@ Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere durable, `-y` to accept prompts. +Ctrl+C stops a transfer and tells the radio to abort it, which costs you the +whole thing: the radio starts the next attempt from the beginning rather than +resuming. Press it twice to quit without waiting for the abort to be sent — see +`abort` below for clearing up after that. + # The pieces Each step is also available alone, for archiving old releases or working away from From e714e8c0eaa178796076872a7331f8c3bb8a6a4c Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:28:47 -0700 Subject: [PATCH 78/85] offer ctrl+c at the point it is useful --- src/benlink/firmware/__init__.py | 6 ++---- src/benlink/firmware/__main__.py | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 05fe86d..9e7fd66 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -30,10 +30,8 @@ Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere durable, `-y` to accept prompts. -Ctrl+C stops a transfer and tells the radio to abort it, which costs you the -whole thing: the radio starts the next attempt from the beginning rather than -resuming. Press it twice to quit without waiting for the abort to be sent — see -`abort` below for clearing up after that. +Ctrl+C during a transfer is safe, but costs you the whole thing: the radio starts +the next attempt from the beginning rather than resuming where it stopped. # The pieces diff --git a/src/benlink/firmware/__main__.py b/src/benlink/firmware/__main__.py index 6b1042e..9ccc4ee 100644 --- a/src/benlink/firmware/__main__.py +++ b/src/benlink/firmware/__main__.py @@ -389,7 +389,8 @@ async def _cmd_update(args: argparse.Namespace) -> int: _out(f"The assembled image has been kept at {path}") return 0 - _out("Do not power off the radio until this finishes.") + _out("Do not power off the radio until this finishes. " + "(Press Ctrl+C to abort safely.)") try: async with _graceful_interrupt(): result = await flash(conn, bundle.data, _make_progress()) @@ -469,7 +470,8 @@ async def _cmd_flash(args: argparse.Namespace) -> int: if not _confirm(question, False, args.yes): return 0 - _out("Do not power off the radio until this finishes.") + _out("Do not power off the radio until this finishes. " + "(Press Ctrl+C to abort safely.)") try: async with _graceful_interrupt(): result = await flash(conn, image, _make_progress()) From 829f3d5438d6d04cbbdeb96b0698c171fa039865 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:30:19 -0700 Subject: [PATCH 79/85] tighten the interrupt note --- src/benlink/firmware/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 9e7fd66..83cea26 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -30,8 +30,7 @@ Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere durable, `-y` to accept prompts. -Ctrl+C during a transfer is safe, but costs you the whole thing: the radio starts -the next attempt from the beginning rather than resuming where it stopped. +Interrupted transfers restart rather than resume. # The pieces From 4c13dbe9e14e179c82485c5ca08ca0531c414be2 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:35:06 -0700 Subject: [PATCH 80/85] say ctrl+c aborts safely, and what it costs --- src/benlink/firmware/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 83cea26..e67e860 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -30,7 +30,7 @@ Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere durable, `-y` to accept prompts. -Interrupted transfers restart rather than resume. +Ctrl+C safely aborts a transfer; the next attempt restarts rather than resumes. # The pieces From 77d75c48fb3ec38b8cd52f7677f4239370031cb9 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:36:27 -0700 Subject: [PATCH 81/85] trim the interrupt note to the affordance --- src/benlink/firmware/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index e67e860..05686da 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -30,7 +30,7 @@ Add `--rfcomm CHANNEL` for RFCOMM instead of BLE, `--keep DIR` to write somewhere durable, `-y` to accept prompts. -Ctrl+C safely aborts a transfer; the next attempt restarts rather than resumes. +Ctrl+C safely aborts a transfer. # The pieces From b8ec5b9ab933ac236cda7574bedc24f0343fe3d8 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:37:29 -0700 Subject: [PATCH 82/85] say what the firmware module is before warning about it --- src/benlink/firmware/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/benlink/firmware/__init__.py b/src/benlink/firmware/__init__.py index 05686da..def5771 100644 --- a/src/benlink/firmware/__init__.py +++ b/src/benlink/firmware/__init__.py @@ -1,4 +1,5 @@ -""" +"""Finding, downloading, assembling and flashing Benshi radio firmware. + # THIS CAN BREAK YOUR RADIO **Flashing firmware can leave your radio unusable, and nothing in this library can From e74c464d7f6b69faa04c4eeefa51e924fcae1548 Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 21:55:06 -0700 Subject: [PATCH 83/85] reword the flashing acknowledgement --- README.md | 6 +++--- src/benlink/__init__.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a2e5a8d..0b94711 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Benlink has already begun to inspire other projects! Here are some that I know of so far: - [HTCommander](https://github.com/Ylianst/HTCommander) -- [flutter\_benlink](https://github.com/SarahRoseLives/flutter_benlink) +- [flutter_benlink](https://github.com/SarahRoseLives/flutter_benlink) If you've found benlink's documentation of the Benshi protocol helpful, or use benlink in your own project, please let me know so I can add it to this list. @@ -127,8 +127,8 @@ receive and sharp questions along the way [@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete flashing proof of concept, working out the gRPC -update check, and having the guts to do the first flash. +firmware protocol into a complete proof of concept for flashing, working out the +gRPC update check, and having the guts to do the first flash. ## Disclaimer diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 831c26c..a984c40 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -86,7 +86,7 @@ async def main(): of so far: - [HTCommander](https://github.com/Ylianst/HTCommander) -- [flutter\\_benlink](https://github.com/SarahRoseLives/flutter_benlink) +- [flutter_benlink](https://github.com/SarahRoseLives/flutter_benlink) If you've found benlink's documentation of the Benshi protocol helpful, or use benlink in your own project, please let me know so I can add it to this list. @@ -120,8 +120,8 @@ async def main(): and sharp questions along the way [@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete flashing proof of concept, working out the gRPC -update check, and having the guts to do the first flash. +firmware protocol into a complete proof of concept for flashing, working out the +gRPC update check, and having the guts to do the first flash. # Disclaimer From e667f7b7c752b772f7f072e2a046ac6bb51b100d Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 22:02:11 -0700 Subject: [PATCH 84/85] add OpenHT to the projects list --- README.md | 1 + src/benlink/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 0b94711..fed87b5 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ of so far: - [HTCommander](https://github.com/Ylianst/HTCommander) - [flutter_benlink](https://github.com/SarahRoseLives/flutter_benlink) +- [OpenHT](https://github.com/repins267/repins267-OpenHT) If you've found benlink's documentation of the Benshi protocol helpful, or use benlink in your own project, please let me know so I can add it to this list. diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index a984c40..243531c 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -87,6 +87,7 @@ async def main(): - [HTCommander](https://github.com/Ylianst/HTCommander) - [flutter_benlink](https://github.com/SarahRoseLives/flutter_benlink) +- [OpenHT](https://github.com/repins267/repins267-OpenHT) If you've found benlink's documentation of the Benshi protocol helpful, or use benlink in your own project, please let me know so I can add it to this list. From 81fffd6cb1361fee0955c3dd5c512e7900495bdd Mon Sep 17 00:00:00 2001 From: Kyle Husmann Date: Sun, 19 Jul 2026 22:06:06 -0700 Subject: [PATCH 85/85] remove scattered --- README.md | 6 +++--- src/benlink/__init__.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fed87b5..2d3fa95 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ receive [@Ylianst](https://github.com/Ylianst) for a steady stream of protocol findings and sharp questions along the way -[@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete proof of concept for flashing, working out the -gRPC update check, and having the guts to do the first flash. +[@repins267](https://github.com/repins267) for turning my notes on the firmware +protocol into a complete proof of concept for flashing, working out the gRPC +update check, and having the guts to do the first flash. ## Disclaimer diff --git a/src/benlink/__init__.py b/src/benlink/__init__.py index 243531c..2e5ec55 100644 --- a/src/benlink/__init__.py +++ b/src/benlink/__init__.py @@ -120,9 +120,9 @@ async def main(): [@Ylianst](https://github.com/Ylianst) for a steady stream of protocol findings and sharp questions along the way -[@repins267](https://github.com/repins267) for turning my scattered notes on the -firmware protocol into a complete proof of concept for flashing, working out the -gRPC update check, and having the guts to do the first flash. +[@repins267](https://github.com/repins267) for turning my notes on the firmware +protocol into a complete proof of concept for flashing, working out the gRPC +update check, and having the guts to do the first flash. # Disclaimer