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
8 changes: 4 additions & 4 deletions docs/tdeck-feature-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Status labels:
| Feature | Status | Evidence | Gap / Next Action |
| --- | --- | --- | --- |
| T-Deck PlatformIO firmware target | Functional, CI-covered, needs hardware validation | `platformio.ini`, `src/main_tdeck.cpp`; `.github/workflows/firmware.yml` builds `pio run -e tdeck`, captures size output, and uploads firmware artifacts | Keep validating board profile against actual T-Deck flash/PSRAM during hardware smoke runs. |
| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs` | OTA service and update UI are not implemented. |
| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs`; serial OTA diagnostics can write a verified image to the inactive slot and select that slot for next boot | Rollback confirmation and update UI are not implemented. |
| Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Use `docs/tdeck-release-checklist.md` for every release hardware pass. |
| Trackball and keyboard input | Functional, needs validation | GPIO interrupts and I2C keyboard polling in `main_tdeck.cpp` | Use the release checklist for hardware input proof; add input regression tests in simulator. |
| Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Hardware flash/smoke checklist needed for every release. |
Expand Down Expand Up @@ -135,10 +135,10 @@ Status labels:
| Device PIN/password | Partial | `docs/tdeck-device-security.md`; serial `security status`, `security set`, `security check`, `security clear`, and `security test`; `security.cfg` stores a salted, iterated SHA-256 verifier instead of plaintext PINs | Add Settings/lock-screen UI, unlock state, forgotten-PIN recovery language, and encrypted-store integration. |
| Encrypted local store | Planned | README Device security note; Phase 12 roadmap | Encrypt messages, keys, identity, and app data when password is set; migrate existing plaintext stores. |
| Wi-Fi credential hardening | Functional for T-Deck, sim file-backed | T-Deck `lz_store_save_wifi/load_wifi` use ESP32 NVS with legacy `wifi.cfg` migration/removal; serial `wifi` reports `cred=nvs` without printing the password | Native simulator intentionally keeps file-backed credentials for repeatable desktop tests; broaden later if encrypted whole-store support lands. |
| OTA firmware update | Planned | Partition table and design spec | Implement download, hash verify, inactive-slot write, rollback UX. |
| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, verified OTA candidate cache, serial inactive-slot writer, and serial boot-slot selection/mark-valid diagnostics | Implement manifest fetch, reboot orchestration, and rollback UX. |
| Feedback Manager | Partial | A minimal service boundary records app notification requests and exposes serial `feedback status|test` plus `app notify test` diagnostics | Centralize LED, buzzer, keyboard/display feedback, DND, priority queues, and emergency behavior. |
| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status` and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them | Implement Wi-Fi fetch/download, binary hash verify, inactive-slot write, boot-partition switch, rollback UX, update UI, and feedback routing. |
| OTA firmware update | Planned/Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics | Wire manifest download, SHA256 verification, inactive-slot writes, ESP32 OTA state calls, and update UI. |
| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, `ota slot-status`, `ota set-test-boot`, `ota mark-valid`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging; inactive-slot writer and boot selection are available as diagnostics | Implement manifest fetch, rollback UX, update UI, and feedback routing. |
| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache; ESP32 OTA begin/write/end path for inactive-slot writes; ESP32 boot partition selection and mark-valid diagnostics | Wire rollback failure handling, reboot UX, and update UI. |
| Feedback Manager | Planned | Design spec section 8 | Centralize LED, buzzer, keyboard/display feedback and DND. |
| Emergency beacon | Planned | Design spec section 12, disabled Emergency UI row | Requires Feedback Manager and dual-network messaging. |
| BLE companion | Partial, needs validation | NimBLE-based Meshtastic GATT service, official UUIDs, raw `ToRadio` writes, queued `FromRadio` reads, `FromNum` notifications, UI toggle, and serial selftest/status | Validate with the official Meshtastic app over BLE before calling V0.5 complete. |
Expand Down
30 changes: 22 additions & 8 deletions docs/tdeck-firmware-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,21 +412,35 @@ Goal: update the OS without USB flashing.

**Status:** partial. Implemented: a bounded `limitlezz.ota_manifest.v1`
validator, cached manifest discovery from SD/appfs, serial `ota status`, serial
`ota test`, and native selftest coverage. Fetch/download, binary hash verify,
inactive-slot write, boot-partition switch, rollback, UI, and feedback routing
remain TODO.
`ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, `ota test`,
`ota slot-status`, `ota set-test-boot`, `ota mark-valid`, verified candidate
firmware cache with exact size/SHA-256 checks, inactive-slot write,
low-level boot partition selection, and native selftest coverage. Manifest
fetch, user-facing reboot orchestration, rollback UI, update screen, and
feedback routing remain TODO.

Deliverables:

- Implement firmware update manifest alongside the app catalog. Implemented:
`docs/tdeck-ota-manifest.md` plus cached manifest diagnostics.
- Download firmware binary over Wi-Fi.
- Verify SHA256 before writing.
- Write to inactive OTA partition.
- Set OTA boot partition and reboot.
- Download firmware binary over Wi-Fi. Implemented for cached manifests:
`ota fetch` downloads `firmware_url` into a temporary candidate file and
promotes it only after exact size and SHA-256 verification.
- Verify SHA256 before writing. Implemented for the candidate cache:
`ota fetch` and `ota stage <path>` both verify against the cached manifest,
preserving any prior verified candidate after failure.
- Write to inactive OTA partition. Implemented as a guarded serial path:
`ota write` writes a verified cached candidate and `ota write-test` copies
the running image for COM8 hardware smoke proof. Both leave boot selection
unchanged.
- Set OTA boot partition and reboot. Low-level selection is implemented:
`ota set-test-boot` copies the running app into the inactive slot and selects
it for the next boot. The reboot remains explicit for COM8 validation and
user-facing confirmation still needs UI work.
- Support rollback if new firmware fails to mark itself healthy. Initial
implementation: a native-tested OTA boot policy chooses clean, pending
verification, mark-valid, and rollback actions before partition/API wiring.
verification, mark-valid, and rollback actions; serial `ota mark-valid`
calls the ESP-IDF mark-valid API for the running app.
- Add update UI with simple confirmation language.
- Route OTA progress and failure state through Feedback Manager.

Expand Down
121 changes: 112 additions & 9 deletions docs/tdeck-ota-manifest.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
# T-Deck OTA Firmware Manifest

This is the first Phase 10 OTA increment: a bounded manifest contract and
diagnostics path. It validates update metadata before any Wi-Fi downloader,
inactive-slot writer, boot-partition switch, or rollback flow trusts it.
This Phase 10 OTA slice defines a bounded manifest contract, a verified
candidate firmware cache, and an inactive-slot writer. It validates update
metadata and candidate binaries before any boot-partition switch or rollback
flow trusts them.

Implemented:

- `ota status` over the USB serial console.
- `ota fetch` over the USB serial console. This downloads the cached
manifest's `firmware_url` over Wi-Fi, writes a bounded temporary file, verifies
exact size and SHA-256, then atomically promotes it to the OTA cache.
- `ota stage <path>` over the USB serial console. This verifies a local
candidate file against the cached manifest and promotes it to the same cache.
- `ota clear` over the USB serial console.
- `ota write` over the USB serial console. This writes the verified candidate
cache into the inactive OTA slot and leaves the boot partition unchanged.
- `ota write-test` over the USB serial console. This copies the currently
running valid firmware image into the inactive OTA slot as a hardware smoke
path, also leaving the boot partition unchanged.
- `ota slot-status` over the USB serial console. This reports the running,
configured boot, and inactive OTA partitions plus OTA image state when the
bootloader exposes it.
- `ota set-test-boot` over the USB serial console. This copies the currently
running image into the inactive slot, selects that inactive slot for the next
boot, and returns without rebooting.
- `ota mark-valid` over the USB serial console. This calls the ESP-IDF
mark-valid API for the currently running app and reports slot status.
- `ota test` over the USB serial console.
- Native simulator selftest coverage for valid and invalid manifests.
- Native simulator selftest coverage for valid/invalid manifests, candidate
staging, inactive-slot writer dispatch, size mismatch rejection,
prior-candidate preservation, and clearing.
- Cached manifest discovery from SD/local storage and the `appfs` partition.

Still TODO:

- fetch the manifest over Wi-Fi
- download the firmware binary
- verify the binary SHA-256 after download
- write to the inactive OTA slot
- set the OTA boot partition and mark the new firmware healthy
- rollback UX and failure recovery
- user-confirmed reboot orchestration after boot-slot selection
- rollback UX and failure recovery beyond the low-level mark-valid hook
- user-facing update screen and Feedback Manager progress routing

## Cache Paths
Expand All @@ -32,6 +51,17 @@ The firmware looks for one cached JSON manifest at the first matching path:
The native simulator uses the same layout under its data directory, for example
`lzdata/ota/manifest.json` and `lzdata/appfs/ota/manifest.json`.

Verified candidates are cached under the primary data directory:

```text
/sd/limitlezz/ota/firmware.bin
```

Downloads and local staging first write `firmware.bin.tmp`. A candidate is
promoted to `firmware.bin` only after exact byte-count and SHA-256 verification
against the cached manifest. A failed stage/download removes only the temporary
file and leaves any prior verified candidate intact.

## Schema

The manifest is a tiny top-level JSON object. The parser is intentionally
Expand Down Expand Up @@ -80,6 +110,7 @@ Fresh hardware with no cached manifest:
```text
lz> ota status
ota manifest: no cached manifest
ota candidate: none (no candidate)
```

Valid cached manifest:
Expand All @@ -88,6 +119,78 @@ Valid cached manifest:
lz> ota status
ota manifest: valid version=0.97.0 channel=beta board=tdeck size=1539920 source=/sd/limitlezz/ota/manifest.json
firmware: https://updates.example/tdeck/0.97.0/firmware.bin
ota candidate: none (no candidate)
```

Verified candidate ready:

```text
lz> ota status
ota manifest: valid version=0.97.0 channel=beta board=tdeck size=1539920 source=/sd/limitlezz/ota/manifest.json
firmware: https://updates.example/tdeck/0.97.0/firmware.bin
ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

Fetch and verify the manifest's firmware URL:

```text
lz> ota fetch
[ok] OTA candidate downloaded and verified
ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

Stage and verify a local file:

```text
lz> ota stage /sd/limitlezz/ota/downloads/firmware.bin
[ok] OTA candidate staged and verified
ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

Write the verified candidate to the inactive OTA slot without changing boot:

```text
lz> ota write
[ok] OTA candidate written to inactive slot; boot unchanged
ota write: ok source=candidate running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=no
```

Hardware smoke the same inactive-slot writer without preparing an SD candidate:

```text
lz> ota write-test
[ok] OTA inactive-slot write selftest passed; boot unchanged
ota write: ok source=running-copy running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=no
```

Inspect and select the test boot slot:

```text
lz> ota slot-status
ota slots: running=ota_0@0x00010000 state=unset boot=ota_0@0x00010000 state=unset inactive=ota_1@0x00510000 boot-matches-running=yes pending=no

lz> ota set-test-boot
[ok] OTA copied current app and selected inactive slot for next boot; reset to test
ota write: ok source=running-copy running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=yes
ota slots: running=ota_0@0x00010000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_1@0x00510000 boot-matches-running=no pending=no
```

After reset, confirm the running slot and mark the image valid:

```text
lz> ota slot-status
ota slots: running=ota_1@0x00510000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_0@0x00010000 boot-matches-running=yes pending=no

lz> ota mark-valid
[ok] OTA running app marked valid
ota slots: running=ota_1@0x00510000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_0@0x00010000 boot-matches-running=yes pending=no
```

Clear the candidate cache:

```text
lz> ota clear
[ok] OTA candidate cleared
```

Built-in parser proof:
Expand Down
38 changes: 35 additions & 3 deletions docs/tdeck-upgrade-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
This guide documents the upgrade path that is available today. LimitlezzOS has
an OTA-capable partition layout, but over-the-air firmware updates are still
roadmap work. Current releases upgrade through a USB flash of an exact release
or GitHub Actions artifact.
or GitHub Actions artifact. The firmware can now verify and cache an OTA
candidate binary, write it to the inactive OTA slot, select a copied-current
image for next boot from serial diagnostics, and mark the running app valid.
It does not yet provide a user-facing OTA update flow or rollback UX.

## Current Support

Expand All @@ -13,13 +16,16 @@ Supported today:
- USB upgrade from an exact `Firmware CI` artifact
- same-version reflash for recovery or validation
- rollback by reflashing a previously saved known-good artifact
- OTA manifest diagnostics plus verified candidate download/staging cache
- serial inactive-slot write diagnostics that leave boot unchanged
- serial boot-slot selection and mark-valid diagnostics for copied-current-image
hardware proof
- persistent SD-backed user data when the SD card and store remain intact
- persistent NVS-backed Wi-Fi credentials on T-Deck hardware

Not supported yet:

- downloading firmware over Wi-Fi
- writing a candidate to the inactive OTA slot from the device UI
- selecting an OTA candidate for boot from the device UI
- automatic rollback based on a firmware health marker
- signed OTA manifests or release-channel selection

Expand Down Expand Up @@ -149,6 +155,32 @@ If Windows loses `COM8` during the USB boot handoff, wait for the T-Deck to
re-enumerate or reset/replug the device. Do not retarget the smoke test to
another COM port unless that device's ownership has been confirmed.

## OTA Candidate Cache And Slot Write

This is a pre-boot diagnostic path, not a full upgrade path yet. With a cached
manifest at `/sd/limitlezz/ota/manifest.json`, the device can download or stage
a candidate firmware image, verify its exact byte count and SHA-256, and keep it
at `/sd/limitlezz/ota/firmware.bin`. The serial writer can then copy that
verified candidate into the inactive OTA slot, or copy the currently running
image into the inactive slot for a hardware smoke test. Both paths leave the
boot partition unchanged.

```sh
ota status
ota fetch
ota stage /sd/limitlezz/ota/downloads/firmware.bin
ota write
ota write-test
ota slot-status
ota set-test-boot
ota mark-valid
ota clear
```

Do not claim an OTA upgrade from this evidence alone. A release still upgrades
only after a verified candidate is selected for boot from the update flow,
confirmed healthy after reboot, and covered by rollback behavior.

## Rollback

Rollback today means reflashing a previous known-good artifact over USB.
Expand Down
42 changes: 36 additions & 6 deletions scripts/tdeck_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,35 @@ def find_esptool_cmd() -> list[str]:
)


def esptool_flash_syntax(esptool_cmd: list[str]) -> dict[str, str]:
probe = subprocess.run(
[*esptool_cmd, "--help"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
help_text = probe.stdout or ""
modern = "write-flash" in help_text or "default-reset" in help_text
if modern:
return {
"before": "default-reset",
"after": "hard-reset",
"write_flash": "write-flash",
"flash_mode": "--flash-mode",
"flash_freq": "--flash-freq",
"flash_size": "--flash-size",
}
return {
"before": "default_reset",
"after": "hard_reset",
"write_flash": "write_flash",
"flash_mode": "--flash_mode",
"flash_freq": "--flash_freq",
"flash_size": "--flash_size",
}


def find_boot_app0(artifact_dir: Path | None = None) -> Path:
if artifact_dir is not None:
bundled = artifact_dir / "boot_app0.bin"
Expand Down Expand Up @@ -105,6 +134,7 @@ def require_artifacts(project_dir: Path, env_name: str, artifact_dir: Path | Non
def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifact_dir: Path | None) -> None:
bootloader, partitions, boot_app0, firmware = require_artifacts(project_dir, env_name, artifact_dir)
esptool_cmd = find_esptool_cmd()
syntax = esptool_flash_syntax(esptool_cmd)
run(
[
*esptool_cmd,
Expand All @@ -115,16 +145,16 @@ def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifa
"--baud",
str(baud),
"--before",
"default-reset",
syntax["before"],
"--after",
"hard-reset",
syntax["after"],
"--no-stub",
"write-flash",
"--flash-mode",
syntax["write_flash"],
syntax["flash_mode"],
"dio",
"--flash-freq",
syntax["flash_freq"],
"80m",
"--flash-size",
syntax["flash_size"],
"16MB",
"0x0",
str(bootloader),
Expand Down
Loading