Add menus for GitHub firmware and OTA preparation
This commit is contained in:
1 parent
67a291bbbd
commit
735ab4b9be
6 files changed
+1064
-210
No files matched your search
+116
-95
@@ -48,123 +48,118 @@ Close other apps using the USB port before starting.
|
||||
|
||||
## Start
|
||||
|
||||
From the MeshCore checkout:
|
||||
From the MeshCore checkout, run:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/ota_wizard.py
|
||||
```
|
||||
|
||||
On Windows, use `py` in place of `python3`. The first run installs USB and
|
||||
verification dependencies in a private Python environment. On Raspberry Pi OS,
|
||||
install `python3-venv` if Python reports that it is missing.
|
||||
On Windows, use `py` in place of `python3`. No command-line options are needed.
|
||||
The menu provides these actions:
|
||||
|
||||
The wizard selects the USB radio and destination automatically when there is
|
||||
only one of each. Otherwise, choose from the displayed list. Enter the
|
||||
destination admin password once, choose firmware, then review the plan and
|
||||
press Enter to start. Password entry is hidden and passwords are never saved.
|
||||
1. **Update a nearby radio** — the default.
|
||||
2. **Check radios / save a preparation request** — inspect without installing.
|
||||
3. **Prepare an update bundle on this computer** — no radios needed.
|
||||
4. **Recover an interrupted update** — restore saved settings.
|
||||
5. **Settings** — change transfer, bootloader, connection, or build preferences.
|
||||
6. **Exit**.
|
||||
|
||||
Firmware choices are:
|
||||
The first live connection installs USB and verification dependencies in a
|
||||
private Python environment if needed. On Raspberry Pi OS, install
|
||||
`python3-venv` if Python reports that it is missing.
|
||||
Preparing a signed input file can also install signature-verification support
|
||||
on first use, without installing the USB software.
|
||||
|
||||
- **Published GitHub firmware:** the default, matched to the exact board and
|
||||
role. A running version that is already current is left in place.
|
||||
- **Custom firmware file:** an application DFU ZIP, BIN, HEX, full mOTA, or a
|
||||
prepared update bundle.
|
||||
- **Build custom Full firmware from source:** offered on a computer. This
|
||||
needs the firmware build tools and Bash; it is not offered by default on
|
||||
a Pi. The generated version is newer than the detected installed version.
|
||||
For an update, the wizard selects the USB radio and destination automatically
|
||||
when there is only one of each. Otherwise, choose from the displayed lists.
|
||||
Enter the destination admin password once, choose firmware, then review the
|
||||
plan and press Enter to start. Password entry is hidden and passwords are
|
||||
never saved. After an action, the menu is shown again.
|
||||
|
||||
The displayed plan includes package sizes, firmware version, and any
|
||||
bootloader or trusted update signer being installed. After a reboot, the
|
||||
wizard checks the running firmware hash and version, or the bootloader's
|
||||
identity and version, before reporting success.
|
||||
## Choose firmware from GitHub releases
|
||||
|
||||
The firmware menu offers:
|
||||
|
||||
- **Latest compatible GitHub release (recommended):** downloads the published
|
||||
stable file for the detected board and role.
|
||||
- **Choose another GitHub release:** lists compatible releases, including
|
||||
clearly labelled previews, so you can choose a version.
|
||||
- **Local firmware file or prepared bundle:** accepts an application DFU ZIP,
|
||||
BIN, HEX, full mOTA, or a bundle prepared on another computer.
|
||||
- **Build custom Full firmware from source:** offered on a computer with the
|
||||
firmware build tools and Bash. It is not offered by default on a Pi.
|
||||
|
||||
You do not need to copy release URLs or manually download published files.
|
||||
The wizard checks the board and role, searches release files for an exact
|
||||
running base when preparing a differential update, and checks for a compatible
|
||||
signed bootloader on supported nRF52 destinations. Firmware that is already
|
||||
installed or older is left in place.
|
||||
|
||||
The final plan shows the package sizes, firmware version, and any bootloader
|
||||
or trusted update signer being installed. After reboot, the wizard checks the
|
||||
running firmware hash and version, or the bootloader identity and version,
|
||||
before reporting success.
|
||||
|
||||
## Differential or full updates
|
||||
|
||||
The wizard looks for the **exact running firmware** in its cache and published
|
||||
GitHub assets. It uses a differential package when it has a compatible builder,
|
||||
can match that exact base, and the resulting package is smaller. A file with
|
||||
the same version label but a different body hash is not a usable base.
|
||||
**Automatic** is the default transfer setting. It uses a differential package
|
||||
when a compatible builder and the **exact running firmware** are available
|
||||
and the package is smaller. A file with the same version label but a different
|
||||
body hash is not a usable base. Published bases are found through GitHub
|
||||
releases; a local base can be selected when the running image was custom.
|
||||
|
||||
On a computer, the differential builder can be prepared automatically with a
|
||||
compatible Rust toolchain. On a Pi without a builder, the default is a full
|
||||
update made by Python without compiling. This requires destination storage
|
||||
that supports full updates, such as the Ikoka Stick's QSPI flash.
|
||||
compatible Rust toolchain. On a Pi without a builder, a full update is made by
|
||||
Python without compiling. The destination must support full updates, such as
|
||||
an Ikoka Stick with working QSPI storage.
|
||||
|
||||
To force a full application transfer, open **Settings**, choose **Full** for
|
||||
the transfer method, then start the update. Settings also lets you turn the
|
||||
bootloader check off for an application-only update, choose a USB port or
|
||||
source type, or set a builder path, custom source folder/version, or cache folder.
|
||||
|
||||
An internal-flash nRF52 destination that requires differential installation
|
||||
cannot use that full fallback. Prepare its bundle on a computer instead:
|
||||
cannot use the full fallback. Use the computer-to-Pi workflow below.
|
||||
|
||||
1. On the Pi, inspect the connected radios:
|
||||
## Prepare on a computer, send from a Pi
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/ota_wizard.py --check
|
||||
```
|
||||
All steps use the same menu; no command-line options are needed.
|
||||
|
||||
Copy the JSON file shown after **Preparation request:** to the computer.
|
||||
It contains public board information and the exact running firmware hash,
|
||||
without the admin password.
|
||||
1. On the Pi, choose **Check radios / save a preparation request**. Connect to
|
||||
the source and select the destination as prompted. Copy the JSON file
|
||||
shown after **Preparation request:** to the computer. It contains public
|
||||
board information and the exact running hash, without the password.
|
||||
2. On the computer, choose **Prepare an update bundle on this computer**.
|
||||
Select the saved request or enter its file path. Choose firmware from
|
||||
GitHub releases, or select a local custom image. The wizard searches for
|
||||
the exact base and offers a local base file if needed. Accept the proposed
|
||||
output filename or choose another one.
|
||||
3. Copy the resulting `.ota.zip` file to the Pi. Choose **Update a nearby
|
||||
radio**, then **Local firmware file or prepared bundle**, and select that
|
||||
ZIP.
|
||||
|
||||
2. On the computer, prepare the update using the request, new firmware, and
|
||||
the exact firmware currently running on the destination:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/ota_wizard.py --prepare-only \
|
||||
--request DEVICE.json --firmware NEW_FIRMWARE.zip \
|
||||
--base RUNNING_FIRMWARE.zip --output update.ota.zip
|
||||
```
|
||||
|
||||
No radio is opened. If the compatible differential builder and all input
|
||||
files are already present, this also works without network access. Add
|
||||
`--motatool /path/to/motatool` to use an existing builder.
|
||||
|
||||
3. Copy `update.ota.zip` back to the Pi and run:
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/ota_wizard.py --firmware update.ota.zip
|
||||
```
|
||||
|
||||
The live destination is checked again before installation. A changed base
|
||||
hash or a different board stops the differential update.
|
||||
|
||||
For an offline full bundle on a destination that supports it, add `--full`
|
||||
to the preparation command. This path needs neither the differential builder
|
||||
The preparation step does not open a radio. When the builder and input files
|
||||
are already present, local preparation also works without network access.
|
||||
For a full bundle on a destination that supports it, choose **Full** in
|
||||
**Settings** before preparation; this needs neither a differential compiler
|
||||
nor USB dependencies.
|
||||
|
||||
## Useful options
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--check` | Authenticate and inspect OTA support without downloading or installing firmware |
|
||||
| `--port /dev/ttyUSB0` | Choose a USB port; Windows example: `--port COM5` |
|
||||
| `--mode repeater` or `--mode companion` | Select the source type if automatic detection is unsuitable |
|
||||
| `--target PUBLIC_KEY` | Choose a destination by its full 64-character key; it must still answer direct discovery |
|
||||
| `--firmware FILE` | Use a local firmware image or prepared bundle |
|
||||
| `--base FILE` | Supply the exact running firmware for differential preparation |
|
||||
| `--full` | Choose a full application transfer when the destination supports it |
|
||||
| `--build` | Build custom Full firmware from this checkout |
|
||||
| `--version 1.18.0.3-custom` | Set the custom build version; it must be newer than the running version |
|
||||
| `--no-bootloader` | Update application firmware only |
|
||||
| `--cache DIRECTORY` | Choose where packages, public requests, and recovery records are saved |
|
||||
| `--yes` | Accept the displayed plan; required choices and credentials must still be supplied |
|
||||
|
||||
For unattended use, provide `MESHCORE_ADMIN_PASSWORD` in the process environment
|
||||
and pass the port, source type, and target key. The wizard does not write that
|
||||
password into its preferences, recovery records, or custom build environment.
|
||||
The live destination is checked again before installation. A changed base
|
||||
hash or a different board stops a differential update.
|
||||
|
||||
## If the connection is interrupted
|
||||
|
||||
Keep the same source radio connected and run the wizard again. It checks the
|
||||
saved source and destination public keys before restoring settings from an
|
||||
unfinished update. To restore settings and exit:
|
||||
Keep the same source radio connected and run the wizard again. Choose
|
||||
**Recover an interrupted update**, then select the saved session by its
|
||||
public-key prefix and progress state. The wizard checks the complete source
|
||||
and destination keys before restoring settings. A normal update also restores an unfinished
|
||||
session for the selected destination before proceeding.
|
||||
|
||||
```bash
|
||||
python3 tools/lora_ota/ota_wizard.py --recover --target PUBLIC_KEY
|
||||
```
|
||||
|
||||
Keep the recovery record until the wizard reports that restoration is
|
||||
complete. A lost install reply is followed by a running-image check; the
|
||||
wizard does not blindly repeat an install command. Temporary radio windows
|
||||
use the existing main-channel settings, so an interrupted process does not
|
||||
leave the destination on a separate update frequency.
|
||||
Keep the recovery record until restoration is complete. A lost install reply
|
||||
is followed by a running-image check; the wizard does not blindly repeat an
|
||||
install command. Temporary radio windows use the existing main-channel
|
||||
settings. Existing temporary schedules block an update before settings are
|
||||
changed, including on older firmware.
|
||||
|
||||
The cache is normally `~/.cache/meshcore/ota` on Linux,
|
||||
`~/Library/Caches/MeshCore/ota` on macOS, or `%LOCALAPPDATA%\MeshCore\ota` on
|
||||
@@ -172,7 +167,33 @@ Windows. It contains firmware, public preparation records, selected board
|
||||
keys, and restoration settings. Trusted signer keys added for an update remain
|
||||
installed on the destination.
|
||||
|
||||
For the individual commands and a manual board-by-board example, see
|
||||
[Station G2 to Ikoka Stick](ota_station_g2_to_ikoka_stick.md). For routed
|
||||
updates and explicit multi-node setup, use the
|
||||
[scripted LoRa OTA guide](lora_ota_automation.md).
|
||||
<details markdown="1">
|
||||
<summary>Optional command-line use for automation</summary>
|
||||
|
||||
Interactive users can use the menus for every step. Existing command-line
|
||||
options remain available for scripts:
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `--check` | Inspect without downloading or installing firmware |
|
||||
| `--port /dev/ttyUSB0` | Choose a port; Windows example: `--port COM5` |
|
||||
| `--mode repeater` or `--mode companion` | Select the source type |
|
||||
| `--target PUBLIC_KEY` | Choose the full destination key; direct discovery is still required |
|
||||
| `--firmware FILE` | Use a local firmware image or prepared bundle |
|
||||
| `--base FILE` | Supply the exact running image for differential preparation |
|
||||
| `--full` | Force a full transfer where supported |
|
||||
| `--build` | Build custom Full firmware from source |
|
||||
| `--no-bootloader` | Update application firmware only |
|
||||
| `--prepare-only --request DEVICE.json --firmware FILE --output update.ota.zip` | Prepare without radios |
|
||||
| `--recover --target PUBLIC_KEY` | Restore settings and exit |
|
||||
| `--yes` | Accept the displayed plan; credentials and required choices must still be supplied |
|
||||
|
||||
For unattended use, provide `MESHCORE_ADMIN_PASSWORD` in the process environment
|
||||
and pass the port, source type, and target key. The password is excluded from
|
||||
preferences, recovery records, and the custom build environment.
|
||||
|
||||
</details>
|
||||
|
||||
For individual commands and a manual board example, see
|
||||
[Station G2 to Ikoka Stick](ota_station_g2_to_ikoka_stick.md). For routed updates
|
||||
and explicit multi-node setup, use the [scripted LoRa OTA guide](lora_ota_automation.md).
|
||||
+386
-62
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from copy import copy
|
||||
import getpass
|
||||
import importlib.util
|
||||
import importlib.metadata
|
||||
@@ -73,6 +74,8 @@ def arguments(argv=None):
|
||||
parser.add_argument("--recover", action="store_true", help="restore settings from an interrupted update, then exit")
|
||||
parser.add_argument("--yes", action="store_true", help="accept the displayed update plan; does not guess missing credentials")
|
||||
parser.add_argument("--runtime-ready", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--menu-action", choices=("update", "check", "prepare", "recover"),
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument("--check", action="store_true", help="inspect source and destination without preparing or installing an update")
|
||||
parser.add_argument("--prepare-only", action="store_true", help="prepare a portable update bundle on a computer without opening radios")
|
||||
parser.add_argument("--request", type=Path, help="destination preparation request saved by a previous live inspection")
|
||||
@@ -80,6 +83,131 @@ def arguments(argv=None):
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class MenuCancelled(Exception):
|
||||
"""Return to the main menu without changing a radio."""
|
||||
|
||||
|
||||
def file_prompt(prompt: str, default: str | None = None) -> Path:
|
||||
if default and Path(default).expanduser().is_file():
|
||||
selected = select(prompt, ["Use " + default, "Choose another file", "Back"])
|
||||
if selected == 0:
|
||||
return Path(default).expanduser()
|
||||
if selected == 2:
|
||||
raise MenuCancelled
|
||||
while True:
|
||||
value = ask(prompt + " (blank to go back)")
|
||||
if not value:
|
||||
raise MenuCancelled
|
||||
path = Path(value.strip().strip('"').strip("'")).expanduser()
|
||||
if path.is_file():
|
||||
return path
|
||||
print("That file was not found. Enter its path, or leave it blank to go back.")
|
||||
|
||||
|
||||
def menu_settings_path() -> Path:
|
||||
return cache_directory() / "menu-settings.json"
|
||||
|
||||
|
||||
def load_menu_settings(args) -> None:
|
||||
try:
|
||||
values = json.loads(ota.read_bounded_file(menu_settings_path(), 8192, "menu settings"))
|
||||
except (OSError, ValueError, ota.OtaError):
|
||||
return
|
||||
if not isinstance(values, dict):
|
||||
return
|
||||
for field in ("full", "no_bootloader"):
|
||||
if type(values.get(field)) is bool:
|
||||
setattr(args, field, values[field])
|
||||
if values.get("mode") in ("auto", "repeater", "companion"):
|
||||
args.mode = values["mode"]
|
||||
for field in ("motatool", "version", "port"):
|
||||
value = values.get(field)
|
||||
if value is None or isinstance(value, str) and len(value) <= 4096:
|
||||
setattr(args, field, value)
|
||||
for field in ("source_tree", "cache"):
|
||||
value = values.get(field)
|
||||
if isinstance(value, str) and value and len(value) <= 4096:
|
||||
setattr(args, field, Path(value).expanduser())
|
||||
|
||||
|
||||
def settings_menu(args) -> None:
|
||||
while True:
|
||||
print("\nSettings")
|
||||
options = [
|
||||
"Transfer: " + ("Full" if args.full else "Automatic (prefer differential)"),
|
||||
"Check bootloader: " + ("Off" if args.no_bootloader else "On"),
|
||||
"USB source type: " + args.mode.capitalize(),
|
||||
"USB port: " + (args.port or "Automatic"),
|
||||
"Differential builder: " + (args.motatool or "Automatic"),
|
||||
"Custom source folder: " + str(args.source_tree),
|
||||
"Custom version: " + (args.version or "Automatic"),
|
||||
"Download/cache folder: " + str(args.cache),
|
||||
"Back",
|
||||
]
|
||||
choice = select("Setting", options, len(options)-1)
|
||||
if choice == 8:
|
||||
break
|
||||
if choice == 0:
|
||||
args.full = bool(select("Transfer", ["Automatic (prefer differential)", "Full"], int(args.full)))
|
||||
elif choice == 1:
|
||||
args.no_bootloader = bool(select("Check bootloader", ["On", "Off"], int(args.no_bootloader)))
|
||||
elif choice == 2:
|
||||
modes = ("auto", "repeater", "companion")
|
||||
args.mode = modes[select("USB source type", ["Automatic", "Repeater", "Companion"], modes.index(args.mode))]
|
||||
elif choice == 3:
|
||||
args.port = ask("USB port (blank for automatic)") or None
|
||||
elif choice == 4:
|
||||
args.motatool = ask("Differential builder path (blank for automatic)") or None
|
||||
elif choice == 5:
|
||||
args.source_tree = Path(ask("MeshCore source folder", str(args.source_tree))).expanduser()
|
||||
elif choice == 6:
|
||||
args.version = ask("Custom version (blank for automatic)") or None
|
||||
elif choice == 7:
|
||||
args.cache = Path(ask("Download/cache folder", str(args.cache))).expanduser()
|
||||
save_preferences(menu_settings_path(), **{
|
||||
field: str(getattr(args, field)) if field in ("cache", "source_tree") else getattr(args, field)
|
||||
for field in ("full", "no_bootloader", "mode", "port", "motatool", "source_tree", "version", "cache")})
|
||||
|
||||
|
||||
def choose_request(args):
|
||||
records = []
|
||||
for path in sorted((args.cache / "requests").glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True)[:50]:
|
||||
try:
|
||||
records.append((path, load_request(path)))
|
||||
except (ota.OtaError, OSError):
|
||||
continue
|
||||
if not records:
|
||||
return load_request(file_prompt("Preparation request JSON"))
|
||||
choices = [f"{record.hw_id} — {record.public_key[:12].upper()} ({record.version_text})"
|
||||
for _, record in records] + ["Open another preparation request file", "Back"]
|
||||
chosen = select("Preparation request", choices)
|
||||
if chosen == len(records) + 1:
|
||||
raise MenuCancelled
|
||||
return (records[chosen][1] if chosen < len(records)
|
||||
else load_request(file_prompt("Preparation request JSON")))
|
||||
|
||||
|
||||
def choose_recovery(args) -> str:
|
||||
records = []
|
||||
for path in sorted((args.cache / "sessions").glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True)[:50]:
|
||||
try:
|
||||
record = json.loads(ota.read_bounded_file(path, 64 * 1024, "recovery record"))
|
||||
key = record.get("target_key") if isinstance(record, dict) else None
|
||||
if (record.get("format") == 1 and isinstance(key, str)
|
||||
and re.fullmatch(r"[0-9A-Fa-f]{64}", key)
|
||||
and record.get("phase") not in ("finished", "prepared", "inspecting")):
|
||||
records.append((key, record.get("phase", "unfinished")))
|
||||
except (ota.OtaError, OSError, ValueError, AttributeError):
|
||||
continue
|
||||
if not records:
|
||||
print("No interrupted updates were found in this cache.")
|
||||
raise MenuCancelled
|
||||
chosen = select("Interrupted update", [f"{key[:12].upper()} — {phase}" for key, phase in records] + ["Back"])
|
||||
if chosen == len(records):
|
||||
raise MenuCancelled
|
||||
return records[chosen][0]
|
||||
|
||||
|
||||
def ensure_dependencies(args, argv) -> None:
|
||||
required = ("serial", "meshcore", "cryptography")
|
||||
missing = [name for name in required if importlib.util.find_spec(name) is None]
|
||||
@@ -231,6 +359,218 @@ def custom_build(args, destination, target_env: str) -> Path:
|
||||
raise ota.OtaError("The custom build did not produce a matching application image.")
|
||||
|
||||
|
||||
async def latest_asset(catalog, destination):
|
||||
print("Checking GitHub releases for this exact board and role…")
|
||||
try:
|
||||
return await asyncio.to_thread(catalog.choose_asset, target_id=destination.target_id)
|
||||
except ota.OtaError as exc:
|
||||
print(str(exc))
|
||||
return None
|
||||
|
||||
|
||||
async def choose_release(catalog, destination):
|
||||
pages = 3
|
||||
while True:
|
||||
choices = await asyncio.to_thread(catalog.release_choices, target_id=destination.target_id,
|
||||
include_prerelease=True, max_pages=pages)
|
||||
if not choices:
|
||||
print("No compatible firmware was found on these release pages.")
|
||||
labels = [choice.label for choice in choices]
|
||||
more = pages < 10 and catalog.has_more_releases
|
||||
if more:
|
||||
labels.append("Load older GitHub releases")
|
||||
labels.append("Back")
|
||||
selected = select("GitHub release", labels)
|
||||
if selected < len(choices):
|
||||
return choices[selected].asset
|
||||
if more and selected == len(choices):
|
||||
pages = min(10, pages + 3)
|
||||
continue
|
||||
raise MenuCancelled
|
||||
|
||||
|
||||
async def choose_firmware(args, destination, catalog, asset, remembered):
|
||||
if remembered.get("target") != destination.public_key.lower():
|
||||
remembered = {}
|
||||
if args.firmware:
|
||||
return args.firmware, "local"
|
||||
preferred = remembered.get("firmware_source", "latest")
|
||||
while True:
|
||||
source = "build" if args.build else "latest"
|
||||
if not args.build and not args.yes:
|
||||
sources = ["latest", "release", "local"]
|
||||
labels = ["Latest compatible GitHub release (recommended)",
|
||||
"Choose another GitHub release", "Local firmware file or prepared bundle"]
|
||||
if not small_host():
|
||||
sources.append("build")
|
||||
labels.append("Build custom Full firmware from source")
|
||||
default = sources.index(preferred) if preferred in sources else 0
|
||||
selected = select("Firmware", labels + ["Back"], default)
|
||||
if selected == len(sources):
|
||||
raise MenuCancelled
|
||||
source = sources[selected]
|
||||
if source == "local":
|
||||
try:
|
||||
return file_prompt("Firmware ZIP, BIN, HEX, full mOTA, or prepared bundle",
|
||||
remembered.get("firmware_file")), source
|
||||
except MenuCancelled:
|
||||
continue
|
||||
if source == "release":
|
||||
try:
|
||||
selected_asset = await choose_release(catalog, destination)
|
||||
except MenuCancelled:
|
||||
continue
|
||||
else:
|
||||
if asset is None:
|
||||
asset = await latest_asset(catalog, destination)
|
||||
if asset is None:
|
||||
if args.yes or args.build:
|
||||
raise ota.OtaError("No GitHub application matched this board. Choose another release or a local firmware file.")
|
||||
preferred = "local"
|
||||
continue
|
||||
selected_asset = asset
|
||||
if source in ("latest", "release"):
|
||||
print(f"GitHub release: {selected_asset.release_tag}\n {selected_asset.name}")
|
||||
return await asyncio.to_thread(download_asset, selected_asset, args.cache / "downloads"), source
|
||||
if not args.version:
|
||||
args.version = ota.format_version(max(destination.version, selected_asset.version) + 1) + "-custom"
|
||||
return await asyncio.to_thread(custom_build, args, destination, selected_asset.target_env), source
|
||||
|
||||
|
||||
async def choose_missing_base(args, destination, catalog):
|
||||
"""Offer the useful fallbacks only after the automatic exact-base search."""
|
||||
pages = 3
|
||||
while True:
|
||||
choices, actions = [], []
|
||||
if pages < 10:
|
||||
choices.append("Search older GitHub releases for the running firmware")
|
||||
actions.append("github")
|
||||
choices.append("Choose a local copy of the exact running firmware")
|
||||
actions.append("local")
|
||||
if destination.platform == "esp32" or destination.external_storage:
|
||||
choices.append("Use a full update instead")
|
||||
actions.append("full")
|
||||
choices.append("Back")
|
||||
actions.append("back")
|
||||
action = actions[select("The exact running image was not found", choices)]
|
||||
if action == "back":
|
||||
raise MenuCancelled
|
||||
if action == "full":
|
||||
return None
|
||||
if action == "local":
|
||||
supplied = file_prompt("Exact running firmware ZIP, BIN, HEX, or full mOTA")
|
||||
try:
|
||||
return await asyncio.to_thread(find_running_image, destination, args.cache, catalog, supplied=supplied)
|
||||
except ota.OtaError as exc:
|
||||
print(str(exc))
|
||||
continue
|
||||
pages = min(10, pages + 3)
|
||||
try:
|
||||
await asyncio.to_thread(catalog.release_choices, target_id=destination.target_id,
|
||||
include_prerelease=True, max_pages=pages)
|
||||
base = await asyncio.to_thread(find_running_image, destination, args.cache, catalog,
|
||||
candidate_limit=16 if pages < 10 else 32)
|
||||
if base:
|
||||
return base
|
||||
if not catalog.has_more_releases:
|
||||
pages = 10
|
||||
print("No downloaded GitHub candidate matched the exact running image hash.")
|
||||
except ota.OtaError as exc:
|
||||
print(str(exc))
|
||||
|
||||
|
||||
async def prepare_selected_firmware(args, destination, firmware, catalog):
|
||||
prepared = await asyncio.to_thread(load_bundle, firmware, destination)
|
||||
if prepared:
|
||||
image, application = prepared
|
||||
if args.full and not application.info.is_full:
|
||||
application = await asyncio.to_thread(prepare_application, image, destination, args.cache, full=True)
|
||||
else:
|
||||
image = await asyncio.to_thread(load_application, firmware,
|
||||
target_id=destination.target_id, hw_id=destination.hw_id)
|
||||
application = await asyncio.to_thread(load_ready_application, firmware, image, destination)
|
||||
ImageCache(args.cache).remember(image)
|
||||
if image.fw_version <= destination.version:
|
||||
print("The selected firmware is already installed or older; keeping the running firmware.")
|
||||
return image, None
|
||||
if application:
|
||||
return image, application
|
||||
builder = None if args.full else find_builder(args.cache, args.motatool)
|
||||
if not args.full and not builder and not small_host():
|
||||
builder = await asyncio.to_thread(install_builder, args.cache)
|
||||
base = None
|
||||
if builder:
|
||||
base = await asyncio.to_thread(find_running_image, destination, args.cache, catalog, supplied=args.base)
|
||||
if base is None and getattr(args, "interactive_menu", False):
|
||||
base = await choose_missing_base(args, destination, catalog)
|
||||
if not builder and not args.full:
|
||||
print("Using a full update: no differential compiler setup is needed on this host.")
|
||||
if destination.platform == "nrf52" and not destination.external_storage and not (base and builder):
|
||||
raise ota.OtaError(
|
||||
"This radio needs an exact-base differential update. On a computer, choose "
|
||||
"Prepare an update bundle and select its saved preparation request. "
|
||||
"A compatible differential builder must be available; its path can be set under Settings.")
|
||||
application = await asyncio.to_thread(prepare_application, image, destination, args.cache,
|
||||
base=base, builder=builder, full=args.full)
|
||||
return image, application
|
||||
|
||||
|
||||
async def prepare_interactively(args) -> int:
|
||||
destination = choose_request(args)
|
||||
print(f"Preparing for {destination.hw_id} — {destination.public_key[:12].upper()} ({destination.version_text})")
|
||||
catalog = GitHubCatalog()
|
||||
args.interactive_menu = True
|
||||
firmware, _ = await choose_firmware(args, destination, catalog, None, {})
|
||||
verification_cache = ota.cryptography_repair_root()
|
||||
if (verification_cache / "cryptography").is_dir():
|
||||
try:
|
||||
ota.activate_cached_cryptography(verification_cache)
|
||||
except ota.OtaError:
|
||||
pass # A working system verifier may already be loaded.
|
||||
try:
|
||||
image, package = await prepare_selected_firmware(args, destination, firmware, catalog)
|
||||
except ImportError:
|
||||
# Unsigned full packaging stays dependency-free. Signed inputs request
|
||||
# their verifier only when it is actually needed; no USB SDK is loaded.
|
||||
await asyncio.to_thread(ensure_signature_support)
|
||||
image, package = await prepare_selected_firmware(args, destination, firmware, catalog)
|
||||
if package is None:
|
||||
print("Choose a newer firmware release to prepare an update.")
|
||||
return 0
|
||||
folder = args.cache / "bundles"
|
||||
default = folder / f"{destination.hw_id}-{ota.format_version(image.fw_version)}.ota.zip"
|
||||
index = 1
|
||||
while default.exists():
|
||||
default = folder / f"{destination.hw_id}-{ota.format_version(image.fw_version)}-{index}.ota.zip"
|
||||
index += 1
|
||||
output = Path(ask("Save update bundle", str(default)).strip().strip('"').strip("'")).expanduser()
|
||||
await asyncio.to_thread(save_bundle, output, image, package)
|
||||
print(f"Prepared {package.info.kind}: {output} ({len(package.blob):,} transfer bytes)")
|
||||
print("Copy this ZIP to the Pi. Choose Update a nearby radio, then Local firmware file or prepared bundle.")
|
||||
return 0
|
||||
|
||||
|
||||
def ensure_signature_support() -> None:
|
||||
root = ota.cryptography_repair_root()
|
||||
if root.is_dir():
|
||||
try:
|
||||
ota.activate_cached_cryptography(root)
|
||||
return
|
||||
except ota.OtaError:
|
||||
pass
|
||||
print("Preparing signature verification support. This is a one-time download.")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
log = root / "setup.log"
|
||||
environment = {key: value for key, value in os.environ.items()
|
||||
if key not in ("MESHCORE_ADMIN_PASSWORD", "MESHCORE_OTA_PASSWORD")}
|
||||
with log.open("w", encoding="utf-8") as output:
|
||||
completed = subprocess.run(ota.cryptography_repair_command(root), env=environment,
|
||||
stdout=output, stderr=subprocess.STDOUT, timeout=600)
|
||||
if completed.returncode:
|
||||
raise ota.OtaError(f"Signature verification support could not be installed. Details: {log}")
|
||||
ota.activate_cached_cryptography(root)
|
||||
|
||||
|
||||
async def run(args) -> int:
|
||||
args.cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if args.target and not re.fullmatch(r"[0-9A-Fa-f]{64}", args.target):
|
||||
@@ -314,65 +654,8 @@ async def run(args) -> int:
|
||||
return 0
|
||||
await session.snapshot()
|
||||
catalog = GitHubCatalog()
|
||||
print("Checking published firmware for this exact board and role…")
|
||||
try:
|
||||
asset = await asyncio.to_thread(catalog.choose_asset, target_id=destination.target_id)
|
||||
except ota.OtaError as exc:
|
||||
asset = None
|
||||
if not args.firmware and not args.build:
|
||||
print(str(exc))
|
||||
choice = 2 if args.build else 1 if args.firmware else 0
|
||||
if not args.yes and not args.firmware and not args.build:
|
||||
options = ["Published GitHub firmware (recommended)", "Custom firmware file"]
|
||||
if not small_host():
|
||||
options.append("Build custom Full firmware from source")
|
||||
default_choice = remembered.get("firmware_choice", 0 if asset else 1)
|
||||
if remembered.get("target") != key.lower() or default_choice not in range(len(options)):
|
||||
default_choice = 0 if asset else 1
|
||||
choice = select("Firmware", options, default_choice)
|
||||
if choice == 0:
|
||||
if not asset:
|
||||
raise ota.OtaError("No published firmware matched this board; supply --firmware or choose a custom build.")
|
||||
firmware = await asyncio.to_thread(download_asset, asset, args.cache / "downloads")
|
||||
elif choice == 1:
|
||||
default_file = remembered.get("firmware_file") if remembered.get("target") == key.lower() else None
|
||||
firmware = args.firmware or Path(ask("Custom application ZIP, BIN, HEX, full mOTA, or prepared bundle", default_file))
|
||||
else:
|
||||
if not asset:
|
||||
raise ota.OtaError("A custom build needs the exact target environment; use a local firmware file for this board.")
|
||||
if not args.version:
|
||||
args.version = ota.format_version(max(destination.version, asset.version) + 1) + "-custom"
|
||||
firmware = await asyncio.to_thread(custom_build, args, destination, asset.target_env)
|
||||
prepared = await asyncio.to_thread(load_bundle, firmware, destination)
|
||||
if prepared:
|
||||
image, application = prepared
|
||||
if args.full and not application.info.is_full:
|
||||
application = await asyncio.to_thread(prepare_application, image, destination, args.cache, full=True)
|
||||
else:
|
||||
image = await asyncio.to_thread(load_application, firmware,
|
||||
target_id=destination.target_id, hw_id=destination.hw_id)
|
||||
application = await asyncio.to_thread(load_ready_application, firmware, image, destination)
|
||||
ImageCache(args.cache).remember(image)
|
||||
if image.fw_version > destination.version and application is None:
|
||||
builder = find_builder(args.cache, args.motatool)
|
||||
if not args.full and not builder and not small_host():
|
||||
builder = await asyncio.to_thread(install_builder, args.cache)
|
||||
base = None
|
||||
if not args.full and builder:
|
||||
base = await asyncio.to_thread(find_running_image, destination, args.cache, catalog,
|
||||
supplied=args.base)
|
||||
if not builder:
|
||||
print("Using a full update: no differential compiler setup is needed on this host.")
|
||||
if not builder and destination.platform == "nrf52" and not destination.external_storage:
|
||||
raise ota.OtaError(
|
||||
"This internal-flash radio requires a differential package. Prepare it on a computer with "
|
||||
f"--prepare-only --request {request_file} --firmware NEW_FIRMWARE --base RUNNING_FIRMWARE "
|
||||
"--output update.ota.zip, then select that ZIP on the Pi.")
|
||||
application = await asyncio.to_thread(prepare_application, image, destination, args.cache,
|
||||
base=base, builder=builder, full=args.full)
|
||||
elif image.fw_version <= destination.version:
|
||||
application = None
|
||||
print("The selected published/application version is already installed or older; keeping the running firmware.")
|
||||
firmware, choice = await choose_firmware(args, destination, catalog, None, remembered)
|
||||
image, application = await prepare_selected_firmware(args, destination, firmware, catalog)
|
||||
bootloader = None
|
||||
if not args.no_bootloader and destination.platform == "nrf52":
|
||||
print("Checking the exact-board bootloader update…")
|
||||
@@ -398,8 +681,8 @@ async def run(args) -> int:
|
||||
destination = await session.install(package, destination)
|
||||
print(f"Verified {package.info.kind} installation.")
|
||||
save_preferences(args.cache / "choices.json", port=port, mode=mode,
|
||||
target=key.lower(), hw=destination.hw_id, firmware_choice=choice,
|
||||
firmware_file=str(firmware) if choice == 1 else None)
|
||||
target=key.lower(), hw=destination.hw_id, firmware_source=choice,
|
||||
firmware_file=str(firmware) if choice == "local" else None)
|
||||
print("Update verified successfully.")
|
||||
return 0
|
||||
finally:
|
||||
@@ -437,18 +720,59 @@ def prepare_without_radios(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def menu_main(args) -> int:
|
||||
load_menu_settings(args)
|
||||
action = args.menu_action
|
||||
while True:
|
||||
if action is None:
|
||||
print("\nMeshCore LoRa updater")
|
||||
actions = ("update", "check", "prepare", "recover", "settings", "exit")
|
||||
action = actions[select("Choose", [
|
||||
"Update a nearby radio", "Check radios / save a preparation request",
|
||||
"Prepare an update bundle on this computer", "Recover an interrupted update",
|
||||
"Settings", "Exit"])]
|
||||
current = copy(args)
|
||||
current.interactive_menu = True
|
||||
current.check, current.recover = action == "check", action == "recover"
|
||||
try:
|
||||
if action == "exit":
|
||||
return 0
|
||||
if action == "settings":
|
||||
settings_menu(args)
|
||||
elif action == "prepare":
|
||||
asyncio.run(prepare_interactively(current))
|
||||
else:
|
||||
# Preserve the chosen menu action if first-run dependency
|
||||
# setup reexecutes inside the private Python environment.
|
||||
ensure_dependencies(current, ["--menu-action", action, "--cache", str(args.cache)])
|
||||
if action == "recover":
|
||||
current.target = choose_recovery(current)
|
||||
asyncio.run(run(current))
|
||||
except MenuCancelled:
|
||||
pass
|
||||
except (ota.OtaError, RuntimeError, OSError, ValueError, ImportError, subprocess.TimeoutExpired) as exc:
|
||||
print(f"Update stopped: {exc}", file=sys.stderr)
|
||||
action = None
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
args = arguments(argv)
|
||||
try:
|
||||
if args.menu_action or not any(value != "--runtime-ready" for value in argv):
|
||||
return menu_main(args)
|
||||
if args.prepare_only:
|
||||
return prepare_without_radios(args)
|
||||
ensure_dependencies(args, argv)
|
||||
return asyncio.run(run(args))
|
||||
except MenuCancelled:
|
||||
return 0
|
||||
except EOFError:
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped. Check the recovery record if the destination was unreachable during cleanup.", file=sys.stderr)
|
||||
return 130
|
||||
except (ota.OtaError, RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
||||
except (ota.OtaError, RuntimeError, OSError, ValueError, ImportError, subprocess.TimeoutExpired) as exc:
|
||||
print(f"Update stopped: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -50,6 +50,30 @@ class ReleaseAsset:
|
||||
return ota.format_version(self.version)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseChoice:
|
||||
"""One exact-board application from a published release or split family."""
|
||||
release_tag: str
|
||||
asset: ReleaseAsset
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
# GitHub tags are source data; do not echo control sequences to a terminal.
|
||||
tag = "".join(character if character.isprintable() else "?"
|
||||
for character in self.release_tag)
|
||||
return tag + (" (prerelease)" if self.prerelease else "")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CatalogRelease:
|
||||
tag: str
|
||||
prerelease: bool
|
||||
published_at: str
|
||||
assets: tuple[ReleaseAsset, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdatePlan:
|
||||
kind: str
|
||||
@@ -199,61 +223,72 @@ class GitHubCatalog:
|
||||
self.opener = opener
|
||||
self._assets: list[ReleaseAsset] | None = None
|
||||
self._all_assets: list[ReleaseAsset] = []
|
||||
self._pages: dict[int, tuple[_CatalogRelease, ...]] = {}
|
||||
self._last_page: int | None = None
|
||||
|
||||
@property
|
||||
def has_more_releases(self) -> bool:
|
||||
return self._last_page is None and len(self._pages) < 10
|
||||
|
||||
def _releases(self, max_pages: int) -> list[_CatalogRelease]:
|
||||
if type(max_pages) is not int or not 1 <= max_pages <= 10:
|
||||
raise ota.OtaError("Catalog page limit must be between 1 and 10")
|
||||
releases = []
|
||||
for page in range(1, max_pages + 1):
|
||||
if self._last_page is not None and page > self._last_page:
|
||||
break
|
||||
if page not in self._pages:
|
||||
# A split family contains thousands of assets. Cache only parsed
|
||||
# application metadata, keeping each response bounded on a Pi.
|
||||
url = f"https://api.github.com/repos/{self.repository}/releases?per_page=10&page={page}"
|
||||
with _open(url, api=True, opener=self.opener) as response:
|
||||
raw = response.read(MAX_CATALOG_BYTES + 1)
|
||||
if len(raw) > MAX_CATALOG_BYTES:
|
||||
raise ota.OtaError("GitHub release catalog is unexpectedly large")
|
||||
try:
|
||||
rows = json.loads(raw)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise ota.OtaError("GitHub returned an invalid release catalog") from exc
|
||||
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
||||
raise ota.OtaError("GitHub returned an invalid release catalog")
|
||||
parsed = []
|
||||
for release in rows:
|
||||
tag = release.get("tag_name")
|
||||
if release.get("draft") or not isinstance(tag, str) or _version(tag) is None:
|
||||
continue
|
||||
assets = release.get("assets", [])
|
||||
if not isinstance(assets, list):
|
||||
raise ota.OtaError("GitHub returned an invalid release asset list")
|
||||
candidates = tuple(candidate for row in assets if isinstance(row, dict)
|
||||
if (candidate := parse_release_asset(row, tag, self.repository)) is not None)
|
||||
published = release.get("published_at") or ""
|
||||
parsed.append(_CatalogRelease(tag, _is_prerelease(release),
|
||||
published if isinstance(published, str) else "", candidates))
|
||||
self._pages[page] = tuple(parsed)
|
||||
if len(rows) < 10:
|
||||
self._last_page = page
|
||||
releases.extend(self._pages[page])
|
||||
# Extra pages loaded through the release menu also become candidates for
|
||||
# exact-base discovery. Their downloaded body hashes remain authoritative.
|
||||
self._all_assets = [asset for page in sorted(self._pages)
|
||||
for release in self._pages[page] for asset in release.assets]
|
||||
return releases
|
||||
|
||||
def fetch_assets(self, *, include_prerelease: bool = False, max_pages: int = 3) -> list[ReleaseAsset]:
|
||||
if not 1 <= max_pages <= 10:
|
||||
raise ota.OtaError("Catalog page limit must be between 1 and 10")
|
||||
releases: list[dict] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
# Release families contain thousands of per-board assets. Small
|
||||
# pages keep GitHub's embedded asset metadata bounded on a Pi.
|
||||
url = f"https://api.github.com/repos/{self.repository}/releases?per_page=10&page={page}"
|
||||
with _open(url, api=True, opener=self.opener) as response:
|
||||
raw = response.read(MAX_CATALOG_BYTES + 1)
|
||||
if len(raw) > MAX_CATALOG_BYTES:
|
||||
raise ota.OtaError("GitHub release catalog is unexpectedly large")
|
||||
try:
|
||||
rows = json.loads(raw)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise ota.OtaError("GitHub returned an invalid release catalog") from exc
|
||||
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
||||
raise ota.OtaError("GitHub returned an invalid release catalog")
|
||||
releases.extend(row for row in rows if not row.get("draft")
|
||||
and (include_prerelease or not _is_prerelease(row)))
|
||||
if len(rows) < 10:
|
||||
break
|
||||
mains = [row for row in releases if re.match(r"^v\d", str(row.get("tag_name", "")), re.I)
|
||||
and _version(row["tag_name"]) is not None]
|
||||
releases = [release for release in self._releases(max_pages)
|
||||
if include_prerelease or not release.prerelease]
|
||||
mains = [release for release in releases if re.match(r"^v\d", release.tag, re.I)]
|
||||
if not mains:
|
||||
raise ota.OtaError("No published main firmware release was found")
|
||||
main = max(mains, key=lambda row: (_version(row["tag_name"]), row.get("published_at") or ""))
|
||||
family = main["tag_name"]
|
||||
result: list[ReleaseAsset] = []
|
||||
historical: list[ReleaseAsset] = []
|
||||
for release in releases:
|
||||
tag = str(release.get("tag_name", ""))
|
||||
release_family = _release_family(tag)
|
||||
if release_family is None:
|
||||
continue
|
||||
rows = release.get("assets", [])
|
||||
if not isinstance(rows, list):
|
||||
raise ota.OtaError("GitHub returned an invalid release asset list")
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
candidate = parse_release_asset(row, tag, self.repository)
|
||||
if candidate is not None:
|
||||
historical.append(candidate)
|
||||
if release_family == family:
|
||||
result.append(candidate)
|
||||
self._all_assets = historical
|
||||
self._assets = result
|
||||
return list(result)
|
||||
main = max(mains, key=lambda release: (_version(release.tag), release.published_at))
|
||||
self._assets = [asset for release in releases if _release_family(release.tag) == main.tag
|
||||
for asset in release.assets]
|
||||
return list(self._assets)
|
||||
|
||||
def candidates(self, target_env: str | None = None, *, target_id: int | None = None,
|
||||
profile: str = "full", include_embedded: bool = False) -> list[ReleaseAsset]:
|
||||
@staticmethod
|
||||
def _compatible(assets, target_env, target_id, profile, include_embedded=False):
|
||||
if target_env is None and target_id is None:
|
||||
raise ota.OtaError("Select an exact firmware target")
|
||||
assets = self.fetch_assets() if self._assets is None else self._assets
|
||||
result = [row for row in assets if (target_env is None or row.target_env == target_env)
|
||||
and (target_id is None or row.target_id == target_id)
|
||||
and (include_embedded or not row.embedded_bootloader)]
|
||||
@@ -261,7 +296,12 @@ class GitHubCatalog:
|
||||
exact = [row for row in result if row.profile == profile]
|
||||
result = exact or [row for row in result if row.profile == "standard"]
|
||||
preference = {"mota": 0, "zip": 1, "bin": 2, "hex": 3}
|
||||
return sorted(result, key=lambda row: (-row.version, preference[row.kind], row.name))
|
||||
return sorted(result, key=lambda row: (-row.version, preference[row.kind], row.name, row.release_tag))
|
||||
|
||||
def candidates(self, target_env: str | None = None, *, target_id: int | None = None,
|
||||
profile: str = "full", include_embedded: bool = False) -> list[ReleaseAsset]:
|
||||
assets = self.fetch_assets() if self._assets is None else self._assets
|
||||
return self._compatible(assets, target_env, target_id, profile, include_embedded)
|
||||
|
||||
def choose_asset(self, target_env: str | None = None, **kwargs) -> ReleaseAsset:
|
||||
choices = self.candidates(target_env, **kwargs)
|
||||
@@ -269,6 +309,35 @@ class GitHubCatalog:
|
||||
raise ota.OtaError("No published application matches this exact board, role, and profile")
|
||||
return choices[0]
|
||||
|
||||
def release_choices(self, target_env: str | None = None, *, target_id: int | None = None,
|
||||
profile: str = "full", include_prerelease: bool = True,
|
||||
max_pages: int = 3) -> list[ReleaseChoice]:
|
||||
"""Browse exact-board releases; loading older pages reuses parsed pages.
|
||||
|
||||
Known split release shards are one family choice. Board-specific preview
|
||||
releases are also selectable here, while choose_asset stays stable-only.
|
||||
No file is downloaded until the caller selects a choice's asset.
|
||||
"""
|
||||
if target_env is None and target_id is None:
|
||||
raise ota.OtaError("Select an exact firmware target")
|
||||
families: dict[str, list[_CatalogRelease]] = {}
|
||||
for release in self._releases(max_pages):
|
||||
if not include_prerelease and release.prerelease:
|
||||
continue
|
||||
family = _release_family(release.tag) or release.tag
|
||||
families.setdefault(family, []).append(release)
|
||||
choices = []
|
||||
for family, releases in families.items():
|
||||
assets = self._compatible([asset for release in releases for asset in release.assets],
|
||||
target_env, target_id, profile)
|
||||
if assets:
|
||||
choices.append(ReleaseChoice(family, assets[0],
|
||||
any(release.prerelease for release in releases),
|
||||
max(release.published_at for release in releases)))
|
||||
choices.sort(key=lambda choice: (choice.asset.version, choice.published_at, choice.release_tag), reverse=True)
|
||||
choices.sort(key=lambda choice: choice.prerelease)
|
||||
return choices
|
||||
|
||||
def base_candidates(self, target_env: str | None = None, *, target_id: int | None = None,
|
||||
fw_version: int | None = None, limit: int = 8) -> list[ReleaseAsset]:
|
||||
"""Find possible old images; their downloaded EndF body hash MUST be checked."""
|
||||
@@ -276,8 +345,8 @@ class GitHubCatalog:
|
||||
raise ota.OtaError("Select an exact firmware target")
|
||||
if not 1 <= limit <= 32:
|
||||
raise ota.OtaError("Base candidate limit must be between 1 and 32")
|
||||
if self._assets is None:
|
||||
self.fetch_assets()
|
||||
if not self._pages:
|
||||
self._releases(3)
|
||||
preference = {"mota": 0, "zip": 1, "bin": 2, "hex": 3}
|
||||
matches = [row for row in self._all_assets
|
||||
if not row.embedded_bootloader
|
||||
|
||||
@@ -88,7 +88,8 @@ def install_builder(cache: Path, progress=print) -> str | None:
|
||||
|
||||
|
||||
def find_running_image(destination: Destination, cache: Path, catalog: GitHubCatalog | None,
|
||||
*, supplied: Path | None = None, progress=print) -> Path | None:
|
||||
*, supplied: Path | None = None, progress=print,
|
||||
candidate_limit: int = 4) -> Path | None:
|
||||
images = ImageCache(cache)
|
||||
if supplied:
|
||||
identity = load_application(supplied, target_id=destination.target_id, hw_id=destination.hw_id)
|
||||
@@ -102,7 +103,7 @@ def find_running_image(destination: Destination, cache: Path, catalog: GitHubCat
|
||||
return found
|
||||
if catalog:
|
||||
for asset in catalog.base_candidates(target_id=destination.target_id,
|
||||
fw_version=destination.version, limit=4):
|
||||
fw_version=destination.version, limit=candidate_limit):
|
||||
try:
|
||||
candidate = download_asset(asset, cache / "downloads")
|
||||
identity = load_application(candidate, target_id=destination.target_id,
|
||||
|
||||
@@ -92,6 +92,110 @@ class CatalogTest(unittest.TestCase):
|
||||
with self.assertRaises(ota.OtaError):
|
||||
source.choose_asset("another_repeater")
|
||||
|
||||
def test_release_menu_groups_shards_and_offers_exact_board_previews(self):
|
||||
shard = f"lora-ota-10-{FAMILY}"
|
||||
preview = "ikoka-v1.18.0.2-alpha2"
|
||||
shard_asset = asset_row(tag=shard)
|
||||
preview_asset = asset_row(f"{ENV}-full-ota-v1.18.0.2-alpha2.zip", preview)
|
||||
old_asset = asset_row(f"{ENV}-full-ota-{OLD_FAMILY}.zip", OLD_FAMILY)
|
||||
rows = [release(preview, [preview_asset], prerelease=True),
|
||||
release(FAMILY, [asset_row(f"{ENV}-full-ota-{FAMILY}.bin")]),
|
||||
release(shard, [shard_asset]),
|
||||
release(f"full-profiles-{FAMILY}", [asset_row(
|
||||
f"{ENV}-reduced-ota-{FAMILY}.zip", f"full-profiles-{FAMILY}")]),
|
||||
release(OLD_FAMILY, [old_asset]),
|
||||
release("v9.0.0-draft", [asset_row()], draft=True),
|
||||
release("another-v8.0.0", [asset_row(f"another_repeater-full-ota-v8.0.0.zip",
|
||||
"another-v8.0.0")])]
|
||||
source = catalog.GitHubCatalog(opener=lambda req, timeout: Response(json.dumps(rows).encode(), req.full_url))
|
||||
choices = source.release_choices(target_id=TARGET)
|
||||
self.assertEqual([choice.release_tag for choice in choices], [FAMILY, OLD_FAMILY, preview])
|
||||
self.assertEqual(choices[0].asset.name, shard_asset["name"])
|
||||
self.assertEqual(choices[0].asset.release_tag, shard)
|
||||
self.assertFalse(choices[0].prerelease)
|
||||
self.assertTrue(choices[-1].prerelease)
|
||||
self.assertIn("prerelease", choices[-1].label)
|
||||
self.assertEqual(source.choose_asset(target_id=TARGET), choices[0].asset)
|
||||
self.assertEqual([choice.release_tag for choice in source.release_choices(
|
||||
target_id=TARGET, include_prerelease=False)], [FAMILY, OLD_FAMILY])
|
||||
self.assertFalse(source.has_more_releases)
|
||||
|
||||
def test_menu_prefers_full_and_does_not_offer_embedded_or_wrong_role(self):
|
||||
rows = [release(FAMILY, [
|
||||
asset_row(f"{ENV}-with-bootloader-2.4.12-full-ota-{FAMILY}.zip"),
|
||||
asset_row(f"ikoka_stick_nrf_33dbm_room_server_lora_ota-full-ota-{FAMILY}.zip"),
|
||||
asset_row(f"{ENV}-reduced-ota-{FAMILY}.zip"),
|
||||
asset_row(f"{ENV}-ota-{FAMILY}.bin"), asset_row()])]
|
||||
source = catalog.GitHubCatalog(opener=lambda req, timeout: Response(json.dumps(rows).encode(), req.full_url))
|
||||
choices = source.release_choices(ENV)
|
||||
self.assertEqual(len(choices), 1)
|
||||
self.assertEqual(choices[0].asset.profile, "full")
|
||||
self.assertEqual(source.release_choices(ENV, profile="reduced")[0].asset.profile, "reduced")
|
||||
self.assertEqual(source.release_choices("no_matching_repeater"), [])
|
||||
|
||||
def test_menu_loads_older_pages_once_and_adds_exact_base_candidates(self):
|
||||
def page_rows(number):
|
||||
if number == 1:
|
||||
head = release(FAMILY, [asset_row()])
|
||||
elif number == 2:
|
||||
head = release(OLD_FAMILY, [asset_row(f"{ENV}-full-ota-{OLD_FAMILY}.zip", OLD_FAMILY)])
|
||||
else:
|
||||
return []
|
||||
return [head] + [release(f"v0.0.0-empty-{number}-{index}") for index in range(9)]
|
||||
calls = []
|
||||
def opener(request, timeout):
|
||||
number = int(request.full_url.rsplit("=", 1)[1])
|
||||
calls.append(number)
|
||||
return Response(json.dumps(page_rows(number)).encode(), request.full_url)
|
||||
source = catalog.GitHubCatalog(opener=opener)
|
||||
self.assertEqual(len(source.release_choices(target_id=TARGET, max_pages=1)), 1)
|
||||
self.assertTrue(source.has_more_releases)
|
||||
self.assertEqual(len(source.release_choices(target_id=TARGET, max_pages=3)), 2)
|
||||
self.assertEqual(len(source.release_choices(target_id=TARGET, max_pages=6)), 2)
|
||||
self.assertEqual(calls, [1, 2, 3])
|
||||
self.assertFalse(source.has_more_releases)
|
||||
self.assertEqual(source.base_candidates(target_id=TARGET, fw_version=0x01110109)[0].release_tag, OLD_FAMILY)
|
||||
self.assertEqual(source.choose_asset(target_id=TARGET).release_tag, FAMILY)
|
||||
self.assertEqual(calls, [1, 2, 3])
|
||||
|
||||
def test_preview_only_menu_still_supplies_an_exact_base_candidate(self):
|
||||
tag = "ikoka-v1.18.0.2-alpha1"
|
||||
rows = [release(tag, [asset_row(f"{ENV}-full-ota-v1.18.0.2-alpha1.zip", tag)], prerelease=True)]
|
||||
source = catalog.GitHubCatalog(opener=lambda req, timeout: Response(json.dumps(rows).encode(), req.full_url))
|
||||
choices = source.release_choices(target_id=TARGET)
|
||||
self.assertEqual(len(choices), 1)
|
||||
self.assertTrue(choices[0].prerelease)
|
||||
self.assertEqual(source.base_candidates(target_id=TARGET, fw_version=0x01120002), [choices[0].asset])
|
||||
with self.assertRaisesRegex(ota.OtaError, "main firmware release"):
|
||||
source.choose_asset(target_id=TARGET)
|
||||
|
||||
def test_menu_page_errors_are_reported_without_caching_partial_results(self):
|
||||
calls = []
|
||||
def opener(request, timeout):
|
||||
page = int(request.full_url.rsplit("=", 1)[1])
|
||||
calls.append(page)
|
||||
if page == 1:
|
||||
rows = [release(FAMILY, [asset_row()])] + [release(f"v0.0.0-empty-{index}") for index in range(9)]
|
||||
return Response(json.dumps(rows).encode(), request.full_url)
|
||||
return Response(b"not a release catalog" if calls.count(2) == 1 else b"[]", request.full_url)
|
||||
source = catalog.GitHubCatalog(opener=opener)
|
||||
self.assertEqual(len(source.release_choices(target_id=TARGET, max_pages=1)), 1)
|
||||
with self.assertRaisesRegex(ota.OtaError, "invalid release catalog"):
|
||||
source.release_choices(target_id=TARGET, max_pages=3)
|
||||
self.assertEqual(len(source.release_choices(target_id=TARGET, max_pages=3)), 1)
|
||||
self.assertEqual(calls, [1, 2, 2])
|
||||
|
||||
def test_menu_validates_target_page_limits_and_terminal_labels(self):
|
||||
source = catalog.GitHubCatalog(opener=lambda req, timeout: Response(b"[]", req.full_url))
|
||||
with self.assertRaisesRegex(ota.OtaError, "exact firmware target"):
|
||||
source.release_choices()
|
||||
for limit in (0, 11, True, "3"):
|
||||
with self.subTest(limit=limit), self.assertRaisesRegex(ota.OtaError, "page limit"):
|
||||
source.release_choices(target_id=TARGET, max_pages=limit)
|
||||
item = catalog.ReleaseChoice("v1.18.0.2\x1b[1m\n", catalog.parse_release_asset(asset_row(), FAMILY), True, "")
|
||||
self.assertNotIn("\x1b", item.label)
|
||||
self.assertNotIn("\n", item.label)
|
||||
|
||||
def test_untrusted_asset_metadata_is_not_selectable(self):
|
||||
for mutation in ({"browser_download_url": "http://github.com/file.zip"},
|
||||
{"browser_download_url": "https://example.com/file.zip"},
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Exercise the normal menu journeys without radios, compilers, or downloads."""
|
||||
import asyncio
|
||||
from contextlib import ExitStack, redirect_stderr, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import lora_ota as ota
|
||||
import ota_wizard as wizard
|
||||
import ota_wizard_bundle as bundles
|
||||
import ota_wizard_packages as packages
|
||||
from ota_wizard_catalog import ReleaseChoice
|
||||
from test_ota_wizard_bundle import KEY, destination, image, signed_package
|
||||
|
||||
|
||||
class WizardMenuTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.files = tempfile.TemporaryDirectory(prefix="meshcore-wizard-menu-")
|
||||
self.addCleanup(self.files.cleanup)
|
||||
self.cache = Path(self.files.name)
|
||||
self.stack = ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.stack.enter_context(mock.patch.object(wizard, "cache_directory", return_value=self.cache))
|
||||
self.stack.enter_context(mock.patch.object(wizard.sys.stdin, "isatty", return_value=True))
|
||||
self.output = io.StringIO()
|
||||
self.stack.enter_context(redirect_stdout(self.output))
|
||||
self.stack.enter_context(redirect_stderr(self.output))
|
||||
|
||||
def answers(self, values):
|
||||
return mock.patch("builtins.input", side_effect=values)
|
||||
|
||||
def test_plain_launch_can_exit_without_usb_or_dependency_setup(self):
|
||||
with self.answers(["6"]), \
|
||||
mock.patch.object(wizard, "ensure_dependencies", side_effect=AssertionError("USB setup")), \
|
||||
mock.patch.object(wizard, "run", side_effect=AssertionError("radio access")):
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
self.assertIn("Update a nearby radio", self.output.getvalue())
|
||||
self.assertIn("Prepare an update bundle on this computer", self.output.getvalue())
|
||||
|
||||
def test_update_and_check_need_only_menu_numbers(self):
|
||||
for action, menu_number in (("update", "1"), ("check", "2")):
|
||||
with self.subTest(action=action), self.answers([menu_number, "6"]), \
|
||||
mock.patch.object(wizard, "ensure_dependencies") as setup, \
|
||||
mock.patch.object(wizard, "run", new_callable=mock.AsyncMock, return_value=0) as run:
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
run.assert_awaited_once()
|
||||
options = run.await_args.args[0]
|
||||
self.assertEqual(options.check, action == "check")
|
||||
self.assertFalse(options.recover)
|
||||
self.assertTrue(options.interactive_menu)
|
||||
self.assertIsNone(options.port)
|
||||
self.assertIsNone(options.target)
|
||||
self.assertEqual(setup.call_args.args[1],
|
||||
["--menu-action", action, "--cache", str(self.cache)])
|
||||
|
||||
def test_runtime_resume_runs_selected_action_before_showing_main_menu(self):
|
||||
with self.answers(["6"]) as input_call, \
|
||||
mock.patch.object(wizard, "ensure_dependencies"), \
|
||||
mock.patch.object(wizard, "run", new_callable=mock.AsyncMock, return_value=0) as run:
|
||||
self.assertEqual(wizard.main(["--runtime-ready", "--menu-action", "check"]), 0)
|
||||
self.assertTrue(run.await_args.args[0].check)
|
||||
self.assertEqual(input_call.call_count, 1) # Only the post-check Exit choice.
|
||||
|
||||
def test_failed_update_returns_to_menu_without_a_traceback(self):
|
||||
with self.answers(["1", "6"]), mock.patch.object(wizard, "ensure_dependencies"), \
|
||||
mock.patch.object(wizard, "run", new_callable=mock.AsyncMock,
|
||||
side_effect=ota.OtaError("Connect a source radio")):
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
self.assertIn("Update stopped: Connect a source radio", self.output.getvalue())
|
||||
self.assertNotIn("Traceback", self.output.getvalue())
|
||||
|
||||
def test_bad_menu_numbers_retry_and_back_cancels_before_usb_setup(self):
|
||||
with self.answers(["nonsense", "0", "3", "", "6"]), \
|
||||
mock.patch.object(wizard, "ensure_dependencies", side_effect=AssertionError("USB setup")):
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
self.assertEqual(self.output.getvalue().count("Enter one of the numbers above."), 2)
|
||||
|
||||
def test_settings_round_trip_without_saving_environment_credentials(self):
|
||||
source, download_cache = self.cache / "custom source", self.cache / "downloads elsewhere"
|
||||
values = ["1", "2", "2", "2", "3", "2", "4", "COM12", "5", "/tools/motatool",
|
||||
"6", str(source), "7", "v1.18.0.4-custom", "8", str(download_cache), "9"]
|
||||
options = wizard.arguments([])
|
||||
with self.answers(values), mock.patch.dict(os.environ, {"MESHCORE_ADMIN_PASSWORD": "never save me"}):
|
||||
wizard.settings_menu(options)
|
||||
stored = json.loads(wizard.menu_settings_path().read_text())
|
||||
self.assertNotIn("never save me", json.dumps(stored))
|
||||
self.assertEqual(set(stored), {"full", "no_bootloader", "mode", "port", "motatool",
|
||||
"source_tree", "version", "cache"})
|
||||
restored = wizard.arguments([])
|
||||
wizard.load_menu_settings(restored)
|
||||
self.assertTrue(restored.full)
|
||||
self.assertTrue(restored.no_bootloader)
|
||||
self.assertEqual((restored.mode, restored.port), ("repeater", "COM12"))
|
||||
self.assertEqual(restored.motatool, "/tools/motatool")
|
||||
self.assertEqual(restored.source_tree, source)
|
||||
self.assertEqual(restored.cache, download_cache)
|
||||
self.assertEqual(restored.version, "v1.18.0.4-custom")
|
||||
|
||||
def test_blank_settings_can_restore_automatic_port_builder_and_version(self):
|
||||
options = wizard.arguments([])
|
||||
options.port, options.motatool, options.version = "COM12", "/tools/motatool", "v1.18.0.4-custom"
|
||||
with self.answers(["4", "", "5", "", "7", "", "9"]):
|
||||
wizard.settings_menu(options)
|
||||
self.assertIsNone(options.port)
|
||||
self.assertIsNone(options.motatool)
|
||||
self.assertIsNone(options.version)
|
||||
|
||||
def test_menu_prepares_real_full_bundle_from_github_and_cached_request(self):
|
||||
request = self.cache / "requests" / (KEY + ".json")
|
||||
bundles.save_request(request, destination())
|
||||
firmware = self.cache / "published.bin"
|
||||
firmware.write_bytes(image().image)
|
||||
asset = SimpleNamespace(release_tag="v1.18.0.2", name="ikoka-repeater-v1.18.0.2.zip")
|
||||
catalog = SimpleNamespace(choose_asset=mock.Mock(return_value=asset))
|
||||
with self.answers(["3", "1", "1", "", "6"]), \
|
||||
mock.patch.object(wizard, "GitHubCatalog", return_value=catalog), \
|
||||
mock.patch.object(wizard, "download_asset", return_value=firmware) as download, \
|
||||
mock.patch.object(wizard, "small_host", return_value=True), \
|
||||
mock.patch.object(wizard, "find_builder", return_value=None), \
|
||||
mock.patch.object(wizard, "ensure_dependencies", side_effect=AssertionError("USB setup")), \
|
||||
mock.patch.object(wizard, "choose_port", side_effect=AssertionError("USB access")), \
|
||||
mock.patch.object(wizard, "install_builder", side_effect=AssertionError("compiler setup")):
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
catalog.choose_asset.assert_called_once_with(target_id=destination().target_id)
|
||||
download.assert_called_once_with(asset, self.cache / "downloads")
|
||||
outputs = list((self.cache / "bundles").glob("*.ota.zip"))
|
||||
self.assertEqual(len(outputs), 1)
|
||||
prepared_image, package = bundles.load_bundle(outputs[0], destination())
|
||||
self.assertEqual(prepared_image.image, image().image)
|
||||
self.assertTrue(package.info.is_full)
|
||||
self.assertEqual(package.body_hash, image().body_hash)
|
||||
self.assertIn("Local firmware file or prepared bundle", self.output.getvalue())
|
||||
|
||||
def test_recovery_menu_lists_only_unfinished_records_and_supplies_full_key(self):
|
||||
folder = self.cache / "sessions"
|
||||
folder.mkdir()
|
||||
(folder / (KEY + ".json")).write_text(json.dumps(
|
||||
{"format": 1, "target_key": KEY, "phase": "needs cleanup"}))
|
||||
(folder / ("22" * 32 + ".json")).write_text(json.dumps(
|
||||
{"format": 1, "target_key": "22" * 32, "phase": "finished"}))
|
||||
(folder / "invalid.json").write_text("not json")
|
||||
with self.answers(["4", "1", "6"]) as input_call, \
|
||||
mock.patch.object(wizard, "ensure_dependencies"), \
|
||||
mock.patch.object(wizard, "run", new_callable=mock.AsyncMock, return_value=0) as run:
|
||||
self.assertEqual(wizard.main([]), 0)
|
||||
self.assertEqual(input_call.call_count, 3)
|
||||
self.assertTrue(run.await_args.args[0].recover)
|
||||
self.assertEqual(run.await_args.args[0].target, KEY)
|
||||
self.assertIn(KEY[:12].upper(), self.output.getvalue())
|
||||
self.assertNotIn(KEY.upper(), self.output.getvalue())
|
||||
self.assertNotIn("finished", self.output.getvalue())
|
||||
|
||||
def test_release_browser_can_load_older_pages_and_marks_previews(self):
|
||||
preview_asset, stable_asset = object(), object()
|
||||
preview = ReleaseChoice("v1.18.0.3-alpha1", preview_asset, True, "2026-10-26")
|
||||
stable = ReleaseChoice("v1.18.0.2", stable_asset, False, "2026-10-25")
|
||||
catalog = SimpleNamespace(has_more_releases=True, release_choices=mock.Mock(
|
||||
side_effect=[[preview], [stable, preview]]))
|
||||
with self.answers(["2", "1"]):
|
||||
chosen = asyncio.run(wizard.choose_release(catalog, destination()))
|
||||
self.assertIs(chosen, stable_asset)
|
||||
self.assertEqual([call.kwargs["max_pages"] for call in catalog.release_choices.call_args_list], [3, 6])
|
||||
self.assertTrue(all(call.kwargs["include_prerelease"] for call in catalog.release_choices.call_args_list))
|
||||
self.assertIn("v1.18.0.3-alpha1 (prerelease)", self.output.getvalue())
|
||||
|
||||
def test_firmware_back_does_not_download_or_open_a_file(self):
|
||||
with self.answers(["5"]), mock.patch.object(wizard, "small_host", return_value=False), \
|
||||
mock.patch.object(wizard, "download_asset", side_effect=AssertionError("download")), \
|
||||
mock.patch.object(wizard, "file_prompt", side_effect=AssertionError("file")), \
|
||||
self.assertRaises(wizard.MenuCancelled):
|
||||
asyncio.run(wizard.choose_firmware(wizard.arguments([]), destination(), object(), None, {}))
|
||||
|
||||
def test_remembered_file_has_an_explicit_back_choice(self):
|
||||
previous = self.cache / "previous.bin"
|
||||
previous.write_bytes(image().image)
|
||||
with self.answers(["3"]) as input_call, self.assertRaises(wizard.MenuCancelled):
|
||||
wizard.file_prompt("Firmware file", str(previous))
|
||||
self.assertEqual(input_call.call_count, 1)
|
||||
self.assertIn("Choose another file", self.output.getvalue())
|
||||
self.assertIn("Back", self.output.getvalue())
|
||||
|
||||
def test_missing_remembered_file_can_cancel_instead_of_retrying_stale_default(self):
|
||||
missing = self.cache / "removed.bin"
|
||||
with self.answers([""]) as input_call, self.assertRaises(wizard.MenuCancelled):
|
||||
wizard.file_prompt("Firmware file", str(missing))
|
||||
self.assertEqual(input_call.call_count, 1)
|
||||
self.assertNotIn("Use " + str(missing), self.output.getvalue())
|
||||
|
||||
def test_previous_boards_local_file_does_not_change_new_board_default(self):
|
||||
previous = self.cache / "other-board.bin"
|
||||
previous.write_bytes(b"other board")
|
||||
published = self.cache / "matching.bin"
|
||||
asset = SimpleNamespace(release_tag="v1.18.0.2", name="matching-board.zip")
|
||||
remembered = {"target": "22" * 32, "firmware_source": "local", "firmware_file": str(previous)}
|
||||
with self.answers([""]), mock.patch.object(wizard, "small_host", return_value=True), \
|
||||
mock.patch.object(wizard, "download_asset", return_value=published) as download, \
|
||||
mock.patch.object(wizard, "file_prompt", side_effect=AssertionError("other board's file")):
|
||||
selected, source = asyncio.run(wizard.choose_firmware(
|
||||
wizard.arguments([]), destination(), object(), asset, remembered))
|
||||
self.assertEqual((selected, source), (published, "latest"))
|
||||
download.assert_called_once_with(asset, self.cache / "downloads")
|
||||
|
||||
def test_local_firmware_selection_does_not_query_github(self):
|
||||
local = self.cache / "local.bin"
|
||||
local.write_bytes(image().image)
|
||||
catalog = SimpleNamespace(choose_asset=mock.Mock(side_effect=AssertionError("GitHub query")),
|
||||
release_choices=mock.Mock(side_effect=AssertionError("GitHub query")))
|
||||
with self.answers(["3", str(local)]), mock.patch.object(wizard, "small_host", return_value=True), \
|
||||
mock.patch.object(wizard, "latest_asset", new_callable=mock.AsyncMock,
|
||||
side_effect=AssertionError("GitHub query")), \
|
||||
mock.patch.object(wizard, "download_asset", side_effect=AssertionError("GitHub download")):
|
||||
selected, source = asyncio.run(wizard.choose_firmware(
|
||||
wizard.arguments([]), destination(), catalog, None, {}))
|
||||
self.assertEqual((selected, source), (local, "local"))
|
||||
catalog.choose_asset.assert_not_called()
|
||||
catalog.release_choices.assert_not_called()
|
||||
|
||||
def test_release_back_returns_to_firmware_menu_then_accepts_local_file(self):
|
||||
local = self.cache / "local.bin"
|
||||
local.write_bytes(image().image)
|
||||
preview = ReleaseChoice("v1.18.0.3-alpha1", object(), True, "2026-10-26")
|
||||
catalog = SimpleNamespace(has_more_releases=False,
|
||||
release_choices=mock.Mock(return_value=[preview]))
|
||||
with self.answers(["2", "2", "3", str(local)]), \
|
||||
mock.patch.object(wizard, "small_host", return_value=True), \
|
||||
mock.patch.object(wizard, "latest_asset", new_callable=mock.AsyncMock,
|
||||
side_effect=AssertionError("latest release query")), \
|
||||
mock.patch.object(wizard, "download_asset", side_effect=AssertionError("preview download")):
|
||||
selected, source = asyncio.run(wizard.choose_firmware(
|
||||
wizard.arguments([]), destination(), catalog, None, {}))
|
||||
self.assertEqual((selected, source), (local, "local"))
|
||||
catalog.release_choices.assert_called_once_with(
|
||||
target_id=destination().target_id, include_prerelease=True, max_pages=3)
|
||||
self.assertIn("v1.18.0.3-alpha1 (prerelease)", self.output.getvalue())
|
||||
|
||||
def test_signed_preparation_installs_verifier_then_retries_the_same_file(self):
|
||||
options = wizard.arguments([])
|
||||
chosen = self.cache / "signed-full.mota"
|
||||
new_image, package = image(), signed_package()
|
||||
chosen.write_bytes(package.blob)
|
||||
catalog = object()
|
||||
with mock.patch.object(wizard, "choose_request", return_value=destination()), \
|
||||
mock.patch.object(wizard, "GitHubCatalog", return_value=catalog), \
|
||||
mock.patch.object(wizard, "latest_asset", new_callable=mock.AsyncMock, return_value=None), \
|
||||
mock.patch.object(wizard, "choose_firmware", new_callable=mock.AsyncMock,
|
||||
return_value=(chosen, "local")) as choose, \
|
||||
mock.patch.object(wizard, "prepare_selected_firmware", new_callable=mock.AsyncMock,
|
||||
side_effect=[ImportError("signature verifier missing"),
|
||||
(new_image, package)]) as prepare, \
|
||||
mock.patch.object(wizard, "ensure_signature_support") as verifier, \
|
||||
mock.patch.object(wizard, "ensure_dependencies", side_effect=AssertionError("USB setup")), \
|
||||
mock.patch.object(wizard, "choose_port", side_effect=AssertionError("USB access")), \
|
||||
mock.patch.object(wizard, "ask", side_effect=lambda prompt, default=None: default):
|
||||
self.assertEqual(asyncio.run(wizard.prepare_interactively(options)), 0)
|
||||
choose.assert_awaited_once()
|
||||
verifier.assert_called_once_with()
|
||||
self.assertEqual(prepare.await_args_list, [mock.call(options, destination(), chosen, catalog)] * 2)
|
||||
outputs = list((self.cache / "bundles").glob("*.ota.zip"))
|
||||
self.assertEqual(len(outputs), 1)
|
||||
_, prepared = bundles.load_bundle(outputs[0], destination())
|
||||
self.assertEqual(prepared.blob, package.blob)
|
||||
self.assertEqual(prepared.signer, package.signer)
|
||||
|
||||
def test_failed_signature_setup_does_not_reselect_or_write_a_bundle(self):
|
||||
options = wizard.arguments([])
|
||||
chosen = self.cache / "signed-full.mota"
|
||||
with mock.patch.object(wizard, "choose_request", return_value=destination()), \
|
||||
mock.patch.object(wizard, "GitHubCatalog", return_value=object()), \
|
||||
mock.patch.object(wizard, "latest_asset", new_callable=mock.AsyncMock, return_value=None), \
|
||||
mock.patch.object(wizard, "choose_firmware", new_callable=mock.AsyncMock,
|
||||
return_value=(chosen, "local")) as choose, \
|
||||
mock.patch.object(wizard, "prepare_selected_firmware", new_callable=mock.AsyncMock,
|
||||
side_effect=ImportError("signature verifier missing")) as prepare, \
|
||||
mock.patch.object(wizard, "ensure_signature_support",
|
||||
side_effect=ota.OtaError("Verifier download failed")) as verifier, \
|
||||
mock.patch.object(wizard, "ensure_dependencies", side_effect=AssertionError("USB setup")), \
|
||||
mock.patch.object(wizard, "save_bundle", side_effect=AssertionError("partial bundle")), \
|
||||
self.assertRaisesRegex(ota.OtaError, "Verifier download failed"):
|
||||
asyncio.run(wizard.prepare_interactively(options))
|
||||
choose.assert_awaited_once()
|
||||
prepare.assert_awaited_once()
|
||||
verifier.assert_called_once_with()
|
||||
self.assertFalse((self.cache / "bundles").exists())
|
||||
|
||||
def test_local_base_fallback_rejects_wrong_image_then_accepts_exact_running_image(self):
|
||||
wrong, correct = self.cache / "wrong.bin", self.cache / "running.bin"
|
||||
wrong.write_bytes(image().image)
|
||||
expected = image(0x01120001, body=b"original application" * 16)
|
||||
correct.write_bytes(expected.image)
|
||||
with self.answers(["2", "2"]), \
|
||||
mock.patch.object(wizard, "file_prompt", side_effect=[wrong, correct]), \
|
||||
mock.patch.object(wizard, "GitHubCatalog", side_effect=AssertionError("network")):
|
||||
base = asyncio.run(wizard.choose_missing_base(wizard.arguments([]), destination(), object()))
|
||||
self.assertIsNotNone(base)
|
||||
self.assertEqual(ota.parse_endf(base.read_bytes()).body_hash, destination().base_hash)
|
||||
self.assertIn("not the exact running firmware", self.output.getvalue())
|
||||
|
||||
def test_older_github_base_search_checks_past_four_same_version_candidates(self):
|
||||
candidates, paths = [object() for _ in range(5)], []
|
||||
for index in range(4):
|
||||
path = self.cache / f"other-build-{index}.bin"
|
||||
path.write_bytes(image(0x01120001, body=bytes([index])*64).image)
|
||||
paths.append(path)
|
||||
exact = self.cache / "running.bin"
|
||||
exact.write_bytes(image(0x01120001, body=b"original application"*16).image)
|
||||
paths.append(exact)
|
||||
catalog = SimpleNamespace(has_more_releases=True, release_choices=mock.Mock(return_value=[]),
|
||||
base_candidates=mock.Mock(side_effect=lambda **kwargs: candidates[:kwargs["limit"]]))
|
||||
with self.answers(["1"]), mock.patch.object(packages, "download_asset",
|
||||
side_effect=lambda asset, cache: paths[candidates.index(asset)]) as download:
|
||||
base = asyncio.run(wizard.choose_missing_base(wizard.arguments([]), destination(), catalog))
|
||||
catalog.release_choices.assert_called_once_with(
|
||||
target_id=destination().target_id, include_prerelease=True, max_pages=6)
|
||||
catalog.base_candidates.assert_called_once_with(
|
||||
target_id=destination().target_id, fw_version=destination().version, limit=16)
|
||||
self.assertEqual(download.call_count, 5)
|
||||
self.assertEqual(ota.parse_endf(base.read_bytes()).body_hash, destination().base_hash)
|
||||
|
||||
def test_existing_automation_flags_do_not_enter_main_menu(self):
|
||||
with mock.patch.object(wizard, "menu_main", side_effect=AssertionError("interactive menu")), \
|
||||
mock.patch.object(wizard, "ensure_dependencies") as setup, \
|
||||
mock.patch.object(wizard, "run", new_callable=mock.AsyncMock, return_value=0) as run:
|
||||
self.assertEqual(wizard.main(["--check", "--port", "COM7"]), 0)
|
||||
setup.assert_called_once()
|
||||
self.assertTrue(run.await_args.args[0].check)
|
||||
self.assertEqual(run.await_args.args[0].port, "COM7")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user