@@ -376,6 +376,20 @@ It is deliberately **not** a `:2323` diag-console command. That console is debug
376376unauthenticated by design, so a trigger living there would be absent from precisely the images most
377377likely to need it.
378378
379+ ** Command inventory** (token + colon suffix; plain token = the ` #78 ` HTTPS fetch above). All ship
380+ in BOTH flavours, gated only by the token being set at build time:
381+
382+ - ` <token>:slots ` (>= 1.3.8): report both slots' FWHS serials + the running index (§17)
383+ - ` <token>:revert ` (>= 1.3.8): boot the other slot if it is strictly older (§17 Path 1)
384+ - ` <token>:backup ` (>= 1.3.9): stream the inactive (stock) slot's raw image (§17)
385+ - ` <token>:wipekv ` (>= 1.3.16): factory-reset the Matter KV (formats both Matter DCT regions,
386+ ` 0x3E0000 ` /` 0x3ED000 ` ) and reboot. The cure for the "previously cloud-paired stock unit"
387+ commissioning wedge: see [ docs/12 §"SendTrustedRootCert wedge"] ( 12-ota-convert-stock-unit.md ) .
388+ Wlan fast-reconnect data is untouched, but the fresh KV has no CHIP network config, so the
389+ device comes back ** uncommissioned in BLE commissioning mode with Wi-Fi down** : plan to
390+ re-commission over BLE (` chip-tool pairing code-wifi ... --bypass-attestation-verifier 1 `
391+ from a laptop in range), then hand off to HA via ` open-commissioning-window ` .
392+
379393** Fails closed.** No ` BREAKGLASS_TOKEN ` in ` ota-release.env ` means the socket is never opened, and
380394the boot log says so instead of staying silent. There is deliberately no default token: a default in
381395a public repo is equivalent to no authentication. Set it in ` ota-release.env ` (gitignored); the
@@ -463,3 +477,112 @@ subscriptions, so treat GUI-free editing as **build-verified, not runtime-verifi
463477clusters still require the GUI plus the ` zzz_generated ` edits. Cheap insurance either way: open the
464478` .zap ` in the GUI once and plain-Save before building, which forces ZAP to re-derive all metadata in
465479one canonical pass.
480+
481+ ## 17. Reverting to stock without opening the case (issue #19 )
482+
483+ Two OTA-only paths back to the stock ConnectLife firmware. Path 1 was ** proven on hardware
484+ 2026-07-21** (office unit, stock sw 2): stock → OTA-convert to 1.3.8 → ` revert --flip ` →
485+ stock boots and rejoins ConnectLife by itself → re-convert to 1.3.8 (full round trip).
486+ Feature-map evidence: ` reverse-engineering/docs/13 ` .
487+
488+ ### Path 1: slot-flip (no payload, preferred when it applies)
489+
490+ A stock→custom OTA conversion writes only the inactive slot, so the stock image stays intact
491+ and signature-valid in the other slot until a ** second** custom OTA overwrites it. Custom
492+ firmware ≥ 1.3.8 has two break-glass commands (same listener as §13, token + colon suffix):
493+
494+ - ` <token>:slots ` → ` ok: fw1_sn=<u> fw2_sn=<u> cur=<idx> ` (FWHS serials of both slots)
495+ - ` <token>:revert ` → invalidates the ** running** image's signature
496+ (` sys_update_ota_set_boot_fw_idx ` ) and resets; the bootloader falls back to the other slot
497+
498+ ```
499+ ota-release.sh revert --flip <unit-ip> [--force]
500+ ```
501+
502+ The script queries ` :slots ` first and refuses unless the other slot's serial is below
503+ ` SERIAL_BASE ` (stock carries serial 100; custom serials are ` SERIAL_BASE + versionInt ` ), so a
504+ flip onto an older ** custom** image needs ` --force ` . Guard inside the firmware too: revert
505+ refuses when the other slot is not older. Returning to custom afterwards = re-run the docs/12
506+ conversion (stock's dormant OTA Requestor).
507+
508+ ** Why it is safe for the cloud binding:** the regions stock needs stay byte-intact under the
509+ custom firmware (constant-scanned against the deployed image): Wi-Fi profile ` 0x2FF000 ` ,
510+ cloud config + dkey ` 0x3DB000 ` , device identity ` 0x3DD000 ` . A reverted unit rejoins
511+ ConnectLife as itself, no re-provisioning. The Matter DCT areas (` 0x3E0000 ` /` 0x3ED000 ` ) are
512+ the clobbered ones, and those only cost the (anyway replaced) stock commissioning.
513+
514+ ** Caveat, virgin units:** slot 2 in the factory dump is not S2292 but an Aug-2023 MP-test
515+ build (` S1798.MP_TEST_VERSION_SE ` , no Matter). The running stock slot (S2292) is what a
516+ conversion preserves, so a first-generation convert flips back to S2292 as intended; just do
517+ not treat "the other slot" as interchangeable before checking ` :slots ` .
518+
519+ ### Path 2: repackage the stock app as a Matter OTA (⚠️ BLOCKED: re-signed image fails to boot, 2026-07-21)
520+
521+ ** Status: host checks all pass, but the re-signed image does NOT boot.** On the office unit a
522+ repackaged payload (stock backup + serial patch + re-HMAC + re-sum, every check green, written
523+ byte-perfect to flash, verified by a post-mortem clip dump) left the unit dark: the boot
524+ attempt visibly ran and crashed (the GD25Q32 QE bit was found CLEARED afterwards, and was
525+ cleared again after a manual re-set + power cycle, i.e. the boot flow touches SR2 and never
526+ gets far enough to re-enable quad). The SAME stock bytes with the factory signature (serial
527+ 100 ) boot fine on the same unit. So the bootloader/ROM acceptance is stricter than the
528+ decoded HMAC+bytesum recipe (docs/13) in some way the host-side verification does not
529+ capture: possibly a second key (OTP/eFuse vs the flash partition hash_key) or an extra
530+ manifest check. Until the prebuilt ` bootloader.axf ` is RE'd on this point, treat
531+ ` --repackage ` /` --apply ` as a brick risk: the only recovery is the CH341A clip.
532+
533+ Recovery recipe that worked (clip): write the unit's own dump (per-unit data preserved) with
534+ fw1 replaced by the ORIGINAL stock slot bytes (from a ` revert --backup ` capture, factory
535+ signature) and fw2 erased to 0xFF; ` ch341flash-full.py ` re-sets QE at the end. Boots stock,
536+ ConnectLife rejoins.
537+
538+ What still holds from the host work:
539+
540+ For units whose stock slot is already overwritten. Needs a stock dump of any W41H1 (per-unit
541+ data is ** not** required: OTA writes only the app slot, and the ` 0x0 ` system data, Wi-Fi
542+ profile, dkey and identity live outside it). The stock image's acceptance criteria as decoded
543+ so far (docs/13): bytes ` 0:32 ` = `HMAC-SHA256(partition hash_key @ flash 0x140,
544+ image[ 0xE0:0x140] )`, plus a 4-byte byte-sum trailer at EOF. No app-level cryptographic
545+ signature. So:
546+
547+ ```
548+ ota-release.sh revert --repackage <stock-dump.bin> # carve fw1 @0x10000, patch serial @+0xF4,
549+ # re-HMAC, re-sum, wrap as rac-stock-v<N>.ota
550+ ota-release.sh revert --apply # stage on the Pi + update_node, verify sw 4
551+ ```
552+
553+ ` --repackage ` first re-verifies the recipe byte-exact against every archived
554+ ` firmware_is-v*.bin ` and the dump's unpatched fw1, and dies loudly on any mismatch. The
555+ revert int is ` max(version.txt, .released-version) + 1 ` and the patched serial follows the
556+ §11 rule (` SERIAL_BASE + int ` ), so the bootloader accepts the "older" stock payload. ` --apply `
557+ verification is stock-aware: success = the unit reports softwareVersion ** 4** (vendor 5004)
558+ sustained, or drops off the fabric; ` .released-version ` is left alone so the next custom OTA
559+ still has to beat the last custom int.
560+
561+ ** Recommended journey.** Right after the FIRST OTA conversion (docs/12), while the stock
562+ image still sits intact in the inactive slot, fetch a copy of it once and keep the file:
563+
564+ ```
565+ ota-release.sh revert --backup <unit-ip> # needs custom firmware >= 1.3.9 (:backup command)
566+ ```
567+
568+ ` --backup ` streams the inactive slot over the break-glass listener and saves it only after
569+ three checks pass (serial < ` SERIAL_BASE ` , HMAC, bytesum trailer). After that, any number of
570+ custom OTAs is safe: even once a second custom OTA overwrites the stock slot,
571+ ` ota-release.sh revert --repackage <backup> ` + ` ota-release.sh revert --apply ` restores
572+ stock over the air. Mind the ** version-consumption rule** : each repackaged revert image
573+ carries serial ` SERIAL_BASE + max(version.txt, .released-version) + 1 ` , burning one fleet
574+ version number, so ` --repackage ` bumps ` version.txt ` past the int it just used (commit the
575+ bump). A later custom OTA at or below that int would tie the bootloader and boot stock.
576+
577+ ### What does not work (investigated, dead ends)
578+
579+ - ** Downloading a public stock image.** The only firmware URL compiled into stock
580+ (` download.hismarttv.com/Content/WifiDeviceVersionFile/<id>.bin ` ) serves an older
581+ module generation (zero 4 KB blocks in common with the W41H1 dump). The device never polls
582+ for versions; file IDs only exist in the cloud API, harvestable by MITM-ing the phone app.
583+ - ** Spoofing the cloud to push HOTA.** Gateway TLS is pinned-CA ` VERIFY_REQUIRED ` (the
584+ VERIFY_NONE path is unreachable dead code), and jcmd v5 is AES-256-CBC + HMAC-SHA256 keyed
585+ by the per-device dkey.
586+ - ** Remote stock-dump capture.** Before 1.3.9 no flash readback path existed in either
587+ firmware; the CH341A clip dump was the only capture route. 1.3.9 adds the ` :backup `
588+ break-glass command (Path 2 journey above), which reads back the inactive slot over the air.
0 commit comments