Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions components/ota/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,20 @@ is the routing id (OTA is **module 0**). OTA layers its message types on it.
The [espp OTA Console](https://esp-cpp.github.io/espp/apps/ota_console.html)
(`web/ota_console.html`) implements this protocol over WebUSB in the browser.

### Command line: build → OTA

The [`python/espp_ota`](python/) tool speaks the same protocol from a terminal.
Because this component ships a `project_include.cmake`, any project using it gets
a build-and-flash-over-USB target — the OTA counterpart to `idf.py flash`:

```sh
pip install pyusb # once (needs a libusb backend)
idf.py ota-usb # builds the app, then OTAs it over USB
```

Or drive it directly: `python -m espp_ota flash build/<app>.bin` (see
[`python/README.md`](python/README.md)).

## Rollback

With `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`, a freshly-installed app boots
Expand Down
63 changes: 63 additions & 0 deletions components/ota/project_include.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# espp `ota` component — build-system integration for OTA-over-USB.
#
# Included automatically by ESP-IDF (in project scope) for any project that uses
# the `ota` component. It registers an `ota-usb` build target so you can build
# and OTA-flash your app over USB in one step, the same way `idf.py flash` works
# for the serial bootloader:
#
# idf.py ota-usb # builds the app, then OTAs it over USB
# idf.py build ota-usb # equivalent explicit form (also works pre-CMake 3.19)
#
# Device/port overrides are read from the environment by the tool, e.g.:
# ESPP_OTA_PID=0x1234 idf.py ota-usb
#
# The work is done by the pure-Python `espp_ota` tool shipped alongside this file
# (components/ota/python/). It needs `pyusb` at flash time (not at build time):
# pip install pyusb
#
# For full control (a specific serial, chunk size, discovery probe, ...) run the
# tool directly: python -m espp_ota flash build/<app>.bin --help

if(NOT TARGET ota-usb)
idf_build_get_property(python PYTHON)
set(__espp_ota_pkg_dir "${CMAKE_CURRENT_LIST_DIR}/python")
# CMAKE_PROJECT_NAME is already set here (the real project() runs before
# idf_build_process includes this file); the app .bin lands in the build dir.
set(__espp_ota_bin "${CMAKE_BINARY_DIR}/${CMAKE_PROJECT_NAME}.bin")

# Prepend our package dir to PYTHONPATH rather than replacing it, so a
# PYTHONPATH the environment already relies on is preserved. Use the host's
# path separator. ($ENV{PYTHONPATH} is the value at configure time, which is
# the same environment `idf.py ota-usb` runs in.)
if(WIN32)
set(__espp_ota_pathsep ";")
else()
set(__espp_ota_pathsep ":")
endif()
set(__espp_ota_pythonpath "${__espp_ota_pkg_dir}")
if(DEFINED ENV{PYTHONPATH} AND NOT "$ENV{PYTHONPATH}" STREQUAL "")
set(__espp_ota_pythonpath "${__espp_ota_pkg_dir}${__espp_ota_pathsep}$ENV{PYTHONPATH}")
endif()

add_custom_target(ota-usb
COMMAND ${CMAKE_COMMAND} -E env "PYTHONPATH=${__espp_ota_pythonpath}"
${python} -m espp_ota flash "${__espp_ota_bin}"
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}"
VERBATIM
USES_TERMINAL
COMMENT "OTA-flashing ${__espp_ota_bin} over USB (espp_ota)")

# `gen_project_binary` (the target that produces the app .bin) is defined
# later in project.cmake, so add the build dependency once this directory
# scope has finished processing. On CMake < 3.19 (no cmake_language(DEFER))
# the target still works via the explicit `idf.py build ota-usb` form.
function(__espp_ota_link_build_dependency)
if(TARGET gen_project_binary)
add_dependencies(ota-usb gen_project_binary)
endif()
endfunction()
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.19")
cmake_language(DEFER DIRECTORY "${CMAKE_SOURCE_DIR}"
CALL __espp_ota_link_build_dependency)
endif()
endif()
105 changes: 105 additions & 0 deletions components/ota/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# espp_ota — OTA over USB from the command line

A small, pure-Python host tool that updates an espp device over USB using the
espp `stream_frame` framing + OTA stream protocol (dispatcher **module 0**) — the
same protocol the on-device [`ota` example](../example/) serves and
[`ota_console.html`](../web/ota_console.html) drives from the browser.

It talks to the device's USB **vendor (WebUSB)** interface (`bInterfaceClass
0xFF`, one bulk IN + one bulk OUT endpoint). The frame codec and OTA protocol are
standard-library only; the USB transport uses [`pyusb`](https://pypi.org/project/pyusb/),
imported lazily.

## Seamless: build → OTA with `idf.py`

If your project uses the espp `ota` component, its `project_include.cmake`
registers an `ota-usb` build target, so you can build and flash over USB in one
step (just like `idf.py flash` does over the serial bootloader):

```sh
pip install pyusb # once (libusb backend: `brew install libusb`, `apt install libusb-1.0-0`)
idf.py ota-usb # builds the app, then OTAs it over USB
# or, equivalently / on CMake < 3.19:
idf.py build ota-usb
```

Override the target device without editing anything (the tool reads these):

```sh
ESPP_OTA_PID=0x1234 idf.py ota-usb
```

## Standalone CLI

Run it directly for full control (or when you already have a `.bin`):

```sh
python -m espp_ota flash build/my_app.bin # BEGIN -> stream -> FINISH
python -m espp_ota flash build/my_app.bin --pid 0x1234 --chunk-size 2048
python -m espp_ota list # list matching USB devices
python -m espp_ota discover # probe the device's dispatcher
```

Installed with the espp wheel it's also available as the `espp-ota` command
(`pip install "espp[usb]"`, or `"espp[usb-ui]"` to also get the `rich` UI).

## Library use

```python
from espp_ota import OtaClient, UsbVendorTransport

with open("build/my_app.bin", "rb") as f:
image = f.read()

with UsbVendorTransport() as t: # default VID/PID 0x1209:0x0d32
OtaClient(t, progress=lambda w, tot: print(w, "/", tot)).flash(image)
```

## Protocol

`module = 0`; requests are host→device, replies device→host (reply flag set).
Flow control is one request in flight — each request waits for its OK/ERROR
reply before the next is sent.

| type | name | dir | payload |
|------|------|-----|---------|
| 0x01 | BEGIN | host→dev | u32 image_size (0 = unknown/streaming) |
| 0x02 | DATA | host→dev | image bytes (1..4096) |
| 0x03 | FINISH | host→dev | — (validate + activate) |
| 0x04 | ABORT | host→dev | — |
| 0x05 | OK | dev→host | u32 bytes_received |
| 0x06 | ERROR | dev→host | u32 code + utf-8 message |
| 0x07 | PROGRESS | dev→host | u32 written, u32 total |

The wire framing is `espp::stream_frame` v2 (magic `0x4F54`, CRC-32); see
`espp_ota/frame.py`. Host tests (codec + a full OTA against a mock device) live
in `tests/test_ota_host.py` and run with plain `python3`.

## Output

The tool draws a [`rich`](https://pypi.org/project/rich/) progress bar (spinner,
bar, %, bytes, transfer speed, ETA) and colorizes status / error lines. Under
`idf.py ota-usb` the tool's stdout/stderr are captured pipes, so the bar is drawn
straight to the controlling terminal (`/dev/tty`, `CONOUT$` on Windows) and still
animates in place. Without a terminal (CI / redirected output) it prints periodic
plain-text lines instead. `rich` is optional — the output degrades to a plain
`\r` bar or text without it. It ships in the ESP-IDF Python environment (so
`idf.py ota-usb` already has it) and is pulled in by `pip install "espp[usb-ui]"`.

`idf.py ota-usb` mid-flash — the rich bar (%, size, transfer speed, ETA) animates
in place even though idf.py captures the tool's output:

![espp_ota flashing over USB](https://github.com/user-attachments/assets/a042481a-2964-4b06-9109-bb2dcb4e355b)

…and on completion:

![espp_ota OTA complete](https://github.com/user-attachments/assets/da8e1b71-c65f-4ecc-9223-e232f8591ceb)

## Requirements

- Python 3.8+
- `pyusb` + a libusb backend (only for the actual USB transport):
- macOS: `brew install libusb`
- Linux: `apt install libusb-1.0-0` (add a udev rule for non-root access)
- Windows: the device advertises WebUSB + MS-OS-2.0, so WinUSB binds
automatically; otherwise bind it once with [Zadig](https://zadig.akeo.ie/).
35 changes: 35 additions & 0 deletions components/ota/python/espp_ota/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""espp_ota — pure-Python host tool to OTA-update an espp device over USB.

Speaks the espp ``stream_frame`` framing + OTA stream protocol (dispatcher
module 0) over the device's USB vendor (WebUSB) interface — the same protocol
``components/ota/web/ota_console.html`` implements in the browser and the
``ota`` example serves on-device.

The codec (:mod:`espp_ota.frame`) and protocol (:mod:`espp_ota.protocol`) are
standard-library only; the USB transport (:mod:`espp_ota.transport`) needs
`pyusb`, imported lazily.

Typical use::

from espp_ota import OtaClient, UsbVendorTransport
with UsbVendorTransport() as t:
OtaClient(t, progress=lambda w, tot: ...).flash(open("app.bin", "rb").read())
"""

from .client import OtaClient
from .protocol import ErrorInfo, MessageType, OtaError, ProgressInfo
from .transport import DEFAULT_PID, DEFAULT_VID, TransportError, UsbVendorTransport

__all__ = [
"OtaClient",
"UsbVendorTransport",
"TransportError",
"OtaError",
"MessageType",
"ErrorInfo",
"ProgressInfo",
"DEFAULT_VID",
"DEFAULT_PID",
]

__version__ = "0.1.0"
6 changes: 6 additions & 0 deletions components/ota/python/espp_ota/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

from .cli import main

if __name__ == "__main__":
sys.exit(main())
Loading
Loading