Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
f3e569b
begin work on firmware flow
khusmann Feb 10, 2025
88b09c1
allow unknown versions of phone_status
khusmann Feb 10, 2025
2a5a78b
add notes for abort flow
khusmann Feb 10, 2025
05a3b65
update enum names
khusmann Feb 10, 2025
85d41f5
add message objects for more vm_control commands
khusmann Feb 10, 2025
eaa694c
fix label
khusmann Feb 10, 2025
4919cc9
label unknown commands
khusmann Feb 10, 2025
3cd8fa5
add bt_notifications
khusmann Feb 12, 2025
8c0fb4c
more vm cmds
khusmann Feb 12, 2025
eac9eac
add update error
khusmann Feb 12, 2025
1ca912f
add class vmu packet
khusmann Feb 12, 2025
47bd605
reorder / relabel message names
khusmann Feb 13, 2025
300d0d6
merge main into firmware branch
khusmann Jul 19, 2026
63156b9
add firmware check/download/assemble with CLI
khusmann Jul 19, 2026
7e58a76
drop note about earlier firmware rpc
khusmann Jul 19, 2026
2dcca75
add --product flag with known radio product ids and patch names
khusmann Jul 19, 2026
c196d0b
allow reusing a local base image when fetching firmware
khusmann Jul 19, 2026
ffb3608
use generated protobuf stubs for the firmware update check
khusmann Jul 19, 2026
16d5af5
move protobuf conversion onto FirmwareInfo and UpdateInfo
khusmann Jul 19, 2026
be3d102
move firmware into its own package and share ImmutableBaseModel via c…
khusmann Jul 19, 2026
19256e6
verify firmware md5s against extracted base and assembled image
khusmann Jul 19, 2026
413ef8b
restructure firmware cli around a guided update flow
khusmann Jul 19, 2026
e6deafa
compare installed and latest firmware versions in update flow
khusmann Jul 19, 2026
930cedd
document the firmware update flow and expose it in the package namespace
khusmann Jul 19, 2026
7f67078
require explicit patch name and base image, make reference md5 optional
khusmann Jul 19, 2026
bd211d4
type the protobuf and device info conversions instead of using Any
khusmann Jul 19, 2026
6b2763a
silence unknown types from the untyped grpc stub
khusmann Jul 19, 2026
2c6b2e5
escape underscore in flutter_benlink link
khusmann Jul 19, 2026
2bc663a
call the update rpc directly instead of via a generated grpc stub
khusmann Jul 19, 2026
8ac53ff
hand-roll the update rpc codec instead of generating it
khusmann Jul 19, 2026
f1461e0
split firmware into fetch and flash modules
khusmann Jul 19, 2026
a6c3f4e
call flash from the cli update flow
khusmann Jul 19, 2026
155ff2b
add acknowledgements
khusmann Jul 19, 2026
df4a074
implement firmware flashing
khusmann Jul 19, 2026
a1e539e
drop the untested-on-hardware caveats from the flashing docs
khusmann Jul 19, 2026
6742506
move the model-dependent reboot note next to the code it describes
khusmann Jul 19, 2026
1da1744
note that flash needs the bluetooth stack too
khusmann Jul 19, 2026
2bc5735
record why 259 radios cannot get different firmware
khusmann Jul 19, 2026
5a56344
correct the reboot note: the ga5wb auto-reboots too
khusmann Jul 19, 2026
a80a159
sharpen the vr-n76 reboot note against what issue #10 actually reports
khusmann Jul 19, 2026
0898ccb
trim the reboot note to what the captures confirm
khusmann Jul 19, 2026
ad0597d
drop the firmware commit handshake todo
khusmann Jul 19, 2026
2a41950
mark the vr-n76 as tested
khusmann Jul 19, 2026
df2917f
lead the firmware docs with a breakage warning; credit the flashing poc
khusmann Jul 19, 2026
e19ce4a
trim the breakage warning to one line
khusmann Jul 19, 2026
65fc105
warn on the flash docstring too
khusmann Jul 19, 2026
0123fb8
say what the server's md5s actually cover in check output
khusmann Jul 19, 2026
10454cc
rename _benshikj to _rpc
khusmann Jul 19, 2026
9871875
pin that an early reply is buffered, not dropped
khusmann Jul 19, 2026
c939ccb
narrow message unions in the flash tests for pyright
khusmann Jul 19, 2026
6af430b
fix strict-mode typing in flash and its tests
khusmann Jul 19, 2026
b5a4ac9
align the flashing warnings
khusmann Jul 20, 2026
45dbf3e
make FlashResult a literal union and FlashError a RuntimeError
khusmann Jul 20, 2026
0120d03
flash takes image bytes; add a flash subcommand for local images
khusmann Jul 20, 2026
f47f517
say plainly what a radio that ignores the reboot request does
khusmann Jul 20, 2026
c7cb85d
name which models the reboot behaviour is confirmed on
khusmann Jul 20, 2026
6dd0b97
drop the btsnoop path from the reboot-byte comment
khusmann Jul 20, 2026
73602f8
attribute the reboot-byte evidence to the btsnoop captures
khusmann Jul 20, 2026
24eecd5
abort the transfer on ctrl+c too
khusmann Jul 20, 2026
8cc9923
record that an interrupted transfer restarts rather than resumes
khusmann Jul 20, 2026
489d80e
refuse to finish an update for a different image
khusmann Jul 20, 2026
0b4f1af
add abort_update and flash --abort as the way out of a stranded update
khusmann Jul 20, 2026
d6e11f8
make abort its own subcommand
khusmann Jul 20, 2026
3d6de2b
add a _vm_disconnect helper to match _vm_connect
khusmann Jul 20, 2026
0456bb7
rename _abort to _abort_transfer to distinguish it from abort_update
khusmann Jul 20, 2026
03e3878
name the image in the flash confirmation
khusmann Jul 20, 2026
33ceedb
register for VMU notifications, without which no reply ever arrives
khusmann Jul 20, 2026
f4d5304
show size, rate and eta while transferring
khusmann Jul 20, 2026
217310d
fix contextmanager annotation
khusmann Jul 20, 2026
30bc8aa
compute the transfer rate over a trailing window
khusmann Jul 20, 2026
9cb226d
cancel the task on ctrl+c so the abort actually gets sent
khusmann Jul 20, 2026
0c51480
wait for the radio to confirm the abort before exiting
khusmann Jul 20, 2026
eacf384
group the firmware exports by what they are for
khusmann Jul 20, 2026
6e11abc
say what abort_update is for
khusmann Jul 20, 2026
144bad6
shorten the abort subcommand to match the other cli verbs
khusmann Jul 20, 2026
c4a20bd
note when abort is needed
khusmann Jul 20, 2026
3be44c7
match the abort_update docs to the cli wording
khusmann Jul 20, 2026
bc4765b
document what ctrl+c does to a transfer
khusmann Jul 20, 2026
e714e8c
offer ctrl+c at the point it is useful
khusmann Jul 20, 2026
829f3d5
tighten the interrupt note
khusmann Jul 20, 2026
4c13dbe
say ctrl+c aborts safely, and what it costs
khusmann Jul 20, 2026
77d75c4
trim the interrupt note to the affordance
khusmann Jul 20, 2026
b8ec5b9
say what the firmware module is before warning about it
khusmann Jul 20, 2026
e74c464
reword the flashing acknowledgement
khusmann Jul 20, 2026
e667f7b
add OpenHT to the projects list
khusmann Jul 20, 2026
81fffd6
remove scattered
khusmann Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -93,7 +93,8 @@ 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)
- [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.
Expand All @@ -113,9 +114,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

Expand All @@ -126,6 +124,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 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

This project is an independent grassroots effort, and is **not** affiliated with
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ classifiers = [
]
requires-python = ">=3.10"

[project.optional-dependencies]
firmware = [
"bsdiff4 >=1.2.0",
"grpcio >=1.60.0",
]

[project.urls]
"Homepage" = "https://github.com/khusmann/benlink"
"Bug Tracker" = "https://github.com/khusmann/benlink/issues"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
pythonpath = ["src"]
18 changes: 12 additions & 6 deletions src/benlink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -86,7 +86,8 @@ 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)
- [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.
Expand All @@ -106,9 +107,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))
- [ ] 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

Expand All @@ -119,6 +117,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 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

This project is an independent grassroots effort, and is **not** affiliated with
Expand All @@ -131,5 +136,6 @@ async def main():
from . import controller
from . import command
from . import audio
from . import firmware

__all__ = ['controller', 'command', 'audio']
__all__ = ['controller', 'command', 'audio', 'firmware']
40 changes: 32 additions & 8 deletions src/benlink/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@
from __future__ import annotations
import typing as t
import asyncio
from pydantic import BaseModel, ConfigDict
import contextlib
from . import protocol as p
from .common import ImmutableBaseModel
from .link import CommandLink, BleCommandLink, RfcommCommandLink
from datetime import datetime

Expand Down Expand Up @@ -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.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
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()
Expand Down Expand Up @@ -241,13 +272,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:
Expand Down
16 changes: 16 additions & 0 deletions src/benlink/common.py
Original file line number Diff line number Diff line change
@@ -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"""
140 changes: 140 additions & 0 deletions src/benlink/firmware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""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
undo it.**

# Disclaimer

**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.

# 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
```

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.

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 pieces

Each step is also available alone, for archiving old releases or working away from
the radio. Everything but `info`, `flash` and `abort` avoids the Bluetooth stack.

```bash
# 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

# 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 (if you didn't exit cleanly)
python -m benlink.firmware abort XX:XX:XX:XX:XX:XX
```

`--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 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 ._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 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__ = [
# Which radios and base images exist
"PRODUCTS",
"BASE_IMAGES",

# What a release looks like
"FirmwareInfo",
"UpdateInfo",
"FirmwareBundle",
"ProgressCallback",

# Finding one
"check_update",
"oss_update_info",
"oss_patch_url",
"oss_base_url",

# Downloading and assembling it
"fetch_firmware",
"download_firmware",
"download",
"extract_base",
"assemble",

# Putting it on the radio
"flash",
"abort_update",
"FlashResult",
"FlashError",
]
Loading
Loading