From c3867131285ecd0f3ddc6c7c644b4d71282f5a84 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 7 Sep 2026 22:00:35 +0800 Subject: [PATCH 1/3] ci: publish traceable example firmware bundles --- .github/workflows/ci.yml | 26 +- examples/README.md | 53 ++++ scripts/package_firmware.py | 355 +++++++++++++++++++++++++ scripts/tests/test_package_firmware.py | 333 +++++++++++++++++++++++ 4 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 scripts/package_firmware.py create mode 100644 scripts/tests/test_package_firmware.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ae955..b7deefc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,10 @@ jobs: -lm -o /tmp/room_pcm_gain_test /tmp/room_pcm_gain_test + - name: Firmware packaging tests + if: matrix.name == 'component-test-app-build' + run: python3 -m unittest discover -s scripts/tests -p 'test_package_firmware.py' -v + - name: Build uses: espressif/esp-idf-ci-action@v1 with: @@ -76,8 +80,28 @@ jobs: ninja -C build -j2 && if [ "${{ matrix.name }}" = "component-test-app-build" ]; then python3 ../../../esp-openclaw-talk/tests/run_host_tests.py --sanitize && - python3 ../../../esp-openclaw-talk/tests/run_threaded_tests.py + python3 ../../../esp-openclaw-talk/tests/run_threaded_tests.py && + python3 -m unittest discover -s ../../../../scripts/tests -p test_package_firmware.py -k lock_reader -v fi && if [ "${{ matrix.name }}" = "waveshare-amoled-room-node" ]; then python3 ../../components/esp-openclaw-room-node/tests/run_lifecycle_host_tests.py --managed-components managed_components + fi && + if [ "${{ matrix.name }}" != "component-test-app-build" ]; then + python3 ../../scripts/package_firmware.py \ + --target "${{ matrix.target }}" \ + --repository "${{ github.repository }}" \ + --event "${{ github.event_name }}" \ + --event-sha "${{ github.sha }}" \ + --pr-head-sha "${{ github.event.pull_request.head.sha }}" \ + --run-id "${{ github.run_id }}" \ + --run-attempt "${{ github.run_attempt }}" \ + --idf-image "espressif/idf:release-v5.5" fi + + - name: Upload firmware + if: matrix.name != 'component-test-app-build' + uses: actions/upload-artifact@v7 + with: + name: firmware-${{ matrix.name }}-${{ matrix.target }}-${{ github.sha }}-${{ github.run_attempt }} + path: ${{ matrix.path }}/build/firmware/ + if-no-files-found: error diff --git a/examples/README.md b/examples/README.md index 0468839..60bfb76 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,59 @@ This directory contains thin board applications. Reusable provisioning and room- - [Waveshare AMOLED Room Node](./waveshare-esp32-s3-touch-amoled-2.06-room-node/README.md) An always-on room client with WakeNet, device AEC, WebRTC Talk, an A2UI/image Canvas, separate node/operator sessions, and an AMOLED-off idle state. - [M5Stack Tab5 Room Node](./m5stack-tab5-room-node/README.md) An ESP32-P4 room client using the Tab5 display revisions, C6 remote Wi-Fi, four-slot TDM audio, camera, sensors, SD, and USB-host/RS-485 status. +## CI Firmware Downloads + +Successful example jobs in the repository's **Actions > CI** runs upload a +`firmware----` artifact. Download and extract +the artifact for your exact board. These are CI builds, not releases or +hardware-qualified images. The generic `esp32-node` CI image targets ESP32-S3; +the Unity test application is not distributed as device firmware. + +Each bundle includes all images from ESP-IDF's flash map, including model +partitions, relocated flash arguments, `FLASHING.md`, `manifest.json`, and +`SHA256SUMS`. No source checkout or ESP-IDF installation is needed to flash. +Install the exact esptool version shown in `FLASHING.md` in a Python virtual +environment and use its command with an explicitly selected serial port. +Verify the extracted files before flashing: + +```sh +# Linux +sha256sum -c SHA256SUMS +# macOS +shasum -a 256 -c SHA256SUMS +``` + +The manifest records the actual checked-out source commit separately from the +GitHub event SHA and PR head SHA. A PR build can be a test merge, not the PR head. +It also records the actual IDF commit/build revision, requested Docker image, +esptool and component-manager versions, submodule commits, and payload hashes. +Selected defaults (including target-specific companions) and the selected custom +or IDF built-in partition CSV are identified by normalized paths and SHA-256 +hashes. Their source contents are not duplicated into the bundle; the referenced +repository/IDF commits identify them. +Tab5 includes the upstream BSP archive pin/hash and the repository-local bridge +identity; it is not described as an unmodified upstream BSP component. + +Sanitized configuration, resolved dependency lock, and build metadata are +provenance records, **not exact rebuild inputs**. The manifest also hashes the +original generated inputs, which packaging leaves untouched. Floating IDF +image tags and dependency ranges still prevent an exact-rebuild guarantee. +Checksums detect corruption; they do not independently authenticate a build. +Only use artifacts from a trusted source commit and workflow run. + +Public artifacts use CI defaults. Packaging rejects known credential-bearing +configuration and secure-boot/encrypted builds. Metadata sanitization cannot +remove secrets already compiled into a binary and is not a binary secret scan. +Provision Wi-Fi and the Gateway through the board's serial console after +flashing. Do not add `--force` or erase-all; back up device data before changing +firmware or partition layouts. + +The Tab5 bundle flashes the **P4 only**. Its C6 must already run compatible +`esp_hosted` 1.4.0 / `esp_wifi_remote` 0.8.5 firmware, as described in the +[Tab5 prerequisites](./m5stack-tab5-room-node/README.md#c6-wi-fi-prerequisite). +Artifacts expire according to repository retention settings; this workflow does +not publish releases or deploy Pages. + ## Directory Structure - `esp32-node/` The generic ESP32 example. diff --git a/scripts/package_firmware.py b/scripts/package_firmware.py new file mode 100644 index 0000000..78ac864 --- /dev/null +++ b/scripts/package_firmware.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Package an existing CI example build; never build or access a device.""" + +import argparse +import copy +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tempfile +from urllib.parse import urlsplit + + +EXAMPLES = { + "esp32-node": "esp32s3", + "esp-box-3-display": "esp32s3", + "waveshare-esp32-s3-touch-amoled-2.06-room-node": "esp32s3", + "m5stack-tab5-room-node": "esp32p4", +} +SECURITY_CONFIG = { + "CONFIG_SECURE_BOOT", + "CONFIG_SECURE_FLASH_ENC_ENABLED", + "CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", + "CONFIG_SECURE_SIGNED_APPS", + "CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", +} +PROVENANCE_NOTE = ( + "Sanitized metadata is provenance, not exact rebuild input. Original input " + "hashes identify the generated files before sanitization. Floating SDK and " + "dependency selectors do not guarantee reproducible rebuilds." +) + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def git(root, *arguments): + return subprocess.check_output( + ["git", "-C", str(root), *arguments], text=True, stderr=subprocess.PIPE + ).rstrip("\n") + + +def checked_file(path, roots): + resolved = path.resolve(strict=True) + if not resolved.is_file() or not any(resolved.is_relative_to(root) for root in roots): + raise ValueError("Input file is outside the approved project/build roots") + return resolved + + +def sanitize(value, roots): + if isinstance(value, dict): + return {key: sanitize(item, roots) for key, item in value.items()} + if isinstance(value, list): + return [sanitize(item, roots) for item in value] + if not isinstance(value, str): + return value + for root, label in roots: + value = value.replace(str(root), label) + if re.match(r"^(?:/|[A-Za-z]:[\\/]|~[/\\])", value): + return "" + if "://" in value: + url = urlsplit(value) + if ( + url.scheme != "https" + or url.hostname not in { + "github.com", "api.github.com", "components.espressif.com", + "api.components.espressif.com", "dl.espressif.com", + } + or url.username or url.password or url.query or url.fragment + ): + return "" + return value + + +def sdkconfig_values(text): + values = {} + for line in text.splitlines(): + if line.startswith("CONFIG_") and "=" in line: + key, value = line.split("=", 1) + values[key] = json.loads(value) if value.startswith('"') else value + for key, value in values.items(): + if key in SECURITY_CONFIG and value == "y": + raise ValueError("Secure boot/encrypted builds are not supported") + credential = re.search(r"(?:SSID|PASSWORD|PASSPHRASE|TOKEN|SECRET|API_KEY|SETUP_CODE)$", key) + if (credential or key == "CONFIG_OPENCLAW_ROOM_GATEWAY_HTTP_BASE_URL") and value != "": + raise ValueError("Credential-bearing configuration must be empty for public firmware") + return values + + +def read_dependency_lock(path): + # Use the same safe YAML reader as ESP-IDF's component manager. + from ruamel.yaml import YAML + dependencies = YAML(typ="safe").load(path.read_text()) + if not isinstance(dependencies, dict): + raise ValueError("Resolved dependency lock must be a mapping") + return dependencies + + +def configuration_inputs(project, build, idf, description, config, normalize): + selected = [] + # IDF applies each defaults file, then its target-specific companion if present. + for name in filter(None, description["config_defaults"].split(";")): + defaults = project / name + selected.append(("defaults", checked_file(defaults, (project, build)))) + companion = defaults.with_name(defaults.name + "." + description["target"]) + if companion.exists(): + selected.append(("target_defaults", checked_file(companion, (project, build)))) + partition_root = project if config.get("CONFIG_PARTITION_TABLE_CUSTOM") == "y" else idf / "components/partition_table" + partition = checked_file(partition_root / config["CONFIG_PARTITION_TABLE_FILENAME"], (project, build, idf)) + selected.append(("partition_table", partition)) + return [ + {"kind": kind, "path": sanitize(str(path), normalize), "sha256": sha256(path)} + for kind, path in selected + ] + + +def submodule_provenance(repo): + result = [] + for line in git(repo, "submodule", "status", "--recursive").splitlines(): + if line[0] != " ": + raise ValueError("Submodules must be initialized at their pinned commits") + commit, path, *_ = line[1:].split() + if git(repo / path, "status", "--porcelain", "--untracked-files=normal"): + raise ValueError("Submodule contains modified source") + result.append({"path": path, "commit": commit}) + return result + + +def tab5_provenance(project, build): + bridge = project / "components/m5stack_tab5/CMakeLists.txt" + text = bridge.read_text() + pin = re.search( + r"URL (https://github\.com/espressif/esp-bsp/archive/([0-9a-f]{40})\.tar\.gz)" + r"\s+URL_HASH SHA256=([0-9a-f]{64})", text, + ) + if not pin: + raise ValueError("Cannot identify the Tab5 BSP source pin") + cache = (build / "CMakeCache.txt").read_text() + if os.environ.get("OPENCLAW_TAB5_BSP_LOCAL_PATH") or re.search( + r"^OPENCLAW_TAB5_BSP_LOCAL_PATH:[^=]+=.+$", cache, re.MULTILINE + ): + raise ValueError("CI artifacts require the pinned Tab5 archive, not a local BSP override") + return { + "upstream_url": pin[1], + "upstream_commit": pin[2], + "upstream_archive_sha256": pin[3], + "integration": "Repository-local source-only BSP bridge; not an unmodified upstream component", + "bridge": "components/m5stack_tab5/CMakeLists.txt", + "bridge_sha256": sha256(bridge), + } + + +def package_firmware(project, build, output, target, provenance, dependencies): + project, build, output = project.resolve(), build.resolve(), output.resolve() + repo = Path(git(project, "rev-parse", "--show-toplevel")).resolve() + if project.parent != repo / "examples" or EXAMPLES.get(project.name) != target: + raise ValueError("Only the four CI example/target combinations may be packaged") + if output.exists() or not output.is_relative_to(build): + raise ValueError("Output must be a new directory inside the build directory") + roots = (project, build) + description_file = checked_file(build / "project_description.json", roots) + flash_file = checked_file(build / "flasher_args.json", roots) + description = json.loads(description_file.read_text()) + flash = json.loads(flash_file.read_text()) + config_file = checked_file(Path(description["config_file"]), roots) + config = sdkconfig_values(config_file.read_text()) + if ( + description["version"] != "1.2" + or description["target"] != target + or config.get("CONFIG_IDF_TARGET") != target + or dependencies.get("target") != target + or flash["extra_esptool_args"]["chip"] != target + or Path(description["project_path"]).resolve() != project + or Path(description["build_dir"]).resolve() != build + ): + raise ValueError("Build metadata schema, project or target mismatch") + idf = Path(description["idf_path"]).resolve() + if idf != Path(os.environ["IDF_PATH"]).resolve(): + raise ValueError("Build metadata does not match the active IDF") + if git(repo, "status", "--porcelain", "--untracked-files=normal"): + raise ValueError("Repository contains modified source") + if git(idf, "status", "--porcelain", "--untracked-files=normal"): + raise ValueError("IDF contains modified source") + if not dependencies.get("dependencies"): + raise ValueError("Resolved dependency lock is empty") + normalize = [(build, ""), (project, ""), (repo, ""), (idf, "")] + lock_file = checked_file(project / "dependencies.lock", roots) + manifest = { + "schema_version": 1, + "example": project.name, + "target": target, + "source": { + "repository": provenance["repository"], + "commit": git(repo, "rev-parse", "HEAD"), + "submodules": submodule_provenance(repo), + }, + "idf": { + "commit": git(idf, "rev-parse", "HEAD"), + "build_revision": description["git_revision"], + "image_requested": provenance["idf_image"], + }, + "tools": { + "esptool": importlib.metadata.version("esptool"), + "idf_component_manager": importlib.metadata.version("idf-component-manager"), + }, + "ci": {key: value for key, value in provenance.items() if key not in ("repository", "idf_image")}, + "metadata_note": PROVENANCE_NOTE, + "configuration_inputs": configuration_inputs(project, build, idf, description, config, normalize), + "original_input_sha256": { + "sdkconfig": sha256(config_file), + "dependencies.lock": sha256(lock_file), + "project_description.json": sha256(description_file), + "flasher_args.json": sha256(flash_file), + }, + } + if project.name == "m5stack-tab5-room-node": + manifest["tab5_bsp"] = tab5_provenance(project, build) + extra = flash["extra_esptool_args"] + if type(extra["stub"]) is not bool: + raise ValueError("Invalid esptool stub setting") + if extra["before"] not in ("default_reset", "no_reset", "no_reset_no_sync") or extra["after"] not in ("hard_reset", "no_reset", "no_reset_stub"): + raise ValueError("Unsupported esptool reset mode") + settings = flash["flash_settings"] + expected_args = [ + "--flash_mode", settings["flash_mode"], "--flash_size", settings["flash_size"], + "--flash_freq", settings["flash_freq"], + ] + if flash["write_flash_args"] != expected_args: + raise ValueError("Unsupported write_flash arguments") + if any(not re.fullmatch(r"[A-Za-z0-9]+", item) for item in settings.values()): + raise ValueError("Invalid flash setting") + images = [] + for offset, filename in flash["flash_files"].items(): + if not re.fullmatch(r"0x[0-9a-fA-F]+", offset): + raise ValueError("Invalid image offset") + source = checked_file(build / filename, roots) + if source.suffix != ".bin" or source.stat().st_size == 0: + raise ValueError("Flash images must be nonempty .bin files") + images.append((int(offset, 16), source, offset, f"images/{int(offset, 16):08x}.bin")) + images.sort() + if not images: + raise ValueError("Empty flash image map") + for previous, current in zip(images, images[1:]): + if previous[0] + previous[1].stat().st_size > current[0]: + raise ValueError("Overlapping flash images") + relocated = copy.deepcopy(flash) + relocated["flash_files"] = {offset: name for _, _, offset, name in images} + for key, entry in flash.items(): + if isinstance(entry, dict) and "encrypted" in entry: + if entry["encrypted"] is not False and entry["encrypted"] != "false": + raise ValueError("Encrypted image entries are not supported") + offset = entry["offset"] + if flash["flash_files"].get(offset) != entry["file"]: + raise ValueError("Named image does not match the flash map") + relocated[key]["file"] = relocated["flash_files"][offset] + elif key not in ("flash_files", "flash_settings", "write_flash_args", "extra_esptool_args"): + raise ValueError("Unsupported flash metadata entry") + for name in ("bootloader", "partition-table", "app"): + if name not in relocated: + raise ValueError("Missing required bootloader, partition table or app image") + arguments = expected_args + [item for _, _, offset, name in images for item in (offset, name)] + global_args = ["--chip", target, "--before", extra["before"], "--after", extra["after"]] + if not extra["stub"]: + global_args.append("--no-stub") + command = "python -m esptool " + shlex.join(global_args) + ' --port PORT write_flash "@flash_args"' + instructions = ( + f"# {project.name} ({target})\n\n" + f"Source commit: {manifest['source']['commit']}\n\n" + "Verify SHA256SUMS before flashing. From this extracted directory, install " + "the recorded esptool version in a Python virtual environment:\n\n" + f" python -m pip install esptool=={manifest['tools']['esptool']}\n\n" + "Replace PORT with the explicitly selected serial port, then run:\n\n" + f" {command}\n\n" + "No source checkout or ESP-IDF installation is needed. Do not add --force " + "or erase-all. Existing device data may be incompatible with this partition " + "layout; back it up before changing firmware. Provision Wi-Fi and the Gateway " + "through the serial console; no credentials are supplied here.\n\n" + "Tab5 bundles contain P4 firmware only; the C6 must already have compatible " + "esp_hosted 1.4.0 / esp_wifi_remote 0.8.5 firmware.\n\n" + f"{PROVENANCE_NOTE}\n" + ) + # Stage only after validation; failed packaging must not leave an uploadable partial bundle. + with tempfile.TemporaryDirectory(prefix=".firmware-", dir=build) as temporary: + stage = Path(temporary) / "firmware" + (stage / "images").mkdir(parents=True) + for _, source, _, name in images: + shutil.copyfile(source, stage / name) + files = { + "flasher_args.json": relocated, + "project_description.sanitized.json": sanitize({ + key: description[key] for key in ( + "version", "project_name", "project_version", "git_revision", "target", + "min_rev", "max_rev", "monitor_baud", "app_bin", "build_components", + ) + }, normalize), + "sdkconfig.sanitized.json": sanitize(config, normalize), + "dependencies.lock.sanitized.json": sanitize(dependencies, normalize), + } + for name, value in files.items(): + (stage / name).write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + (stage / "flash_args").write_text(shlex.join(arguments) + "\n") + (stage / "FLASHING.md").write_text(instructions) + manifest["files"] = { + path.relative_to(stage).as_posix(): {"sha256": sha256(path), "size": path.stat().st_size} + for path in sorted(stage.rglob("*")) if path.is_file() + } + (stage / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + (stage / "SHA256SUMS").write_text("".join( + f"{sha256(path)} {path.relative_to(stage).as_posix()}\n" + for path in sorted(stage.rglob("*")) if path.is_file() + )) + stage.rename(output) + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", required=True, choices=("esp32s3", "esp32p4")) + for name in ("repository", "event", "event-sha", "run-id", "run-attempt", "idf-image"): + parser.add_argument("--" + name, required=True) + parser.add_argument("--pr-head-sha", default="") + args = parser.parse_args() + provenance = vars(args).copy() + provenance.pop("target") + project = Path.cwd() + # ruamel.yaml is already part of ESP-IDF's component-manager environment. + try: + from ruamel.yaml import YAMLError + except ImportError: + parser.exit(1, "Run packaging inside the configured ESP-IDF Python environment.\n") + try: + dependencies = read_dependency_lock(project / "dependencies.lock") + output = package_firmware( + project, project / "build", project / "build/firmware", + args.target, provenance, dependencies, + ) + except ValueError as error: + parser.exit(1, f"Firmware packaging failed: {error}\n") + except (YAMLError, KeyError, TypeError, OSError, subprocess.CalledProcessError) as error: + parser.exit(1, f"Firmware packaging failed: {type(error).__name__}. Check build metadata and public defaults.\n") + print(f"Firmware bundle: {output.relative_to(project)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_package_firmware.py b/scripts/tests/test_package_firmware.py new file mode 100644 index 0000000..b77039b --- /dev/null +++ b/scripts/tests/test_package_firmware.py @@ -0,0 +1,333 @@ +"""Exercise firmware bundle contents and rejection boundaries without ESP-IDF.""" + +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).resolve().parents[1] / "package_firmware.py" +SPEC = importlib.util.spec_from_file_location("package_firmware", SCRIPT) +firmware = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(firmware) +HAS_YAML = importlib.util.find_spec("ruamel") is not None + + +class FirmwareBundleTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="firmware-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.repo = self.root / "repository" + self.idf = self.root / "idf" + self.project = self.repo / "examples/esp32-node" + self.build = self.project / "build" + self.output = self.build / "firmware" + self.build.mkdir(parents=True) + self.idf.mkdir() + partition = self.idf / "components/partition_table/partitions_singleapp_large.csv" + partition.parent.mkdir(parents=True) + partition.write_text("factory,app,factory,0x10000,3M,\n") + self.environment = patch.dict(os.environ, { + "IDF_PATH": str(self.idf), + "OPENCLAW_TAB5_BSP_LOCAL_PATH": "", + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + }) + self.environment.start() + self.addCleanup(self.environment.stop) + self.versions = patch.object( + firmware.importlib.metadata, "version", + side_effect=lambda name: {"esptool": "4.11.0", "idf-component-manager": "2.4.3"}[name], + ) + self.versions.start() + self.addCleanup(self.versions.stop) + for repo in (self.repo, self.idf): + self.git(repo, "init", "-q") + (repo / ".gitignore").write_text("examples/\n") + self.git(repo, "add", ".") + self.git(repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", + "-c", "commit.gpgsign=false", "commit", "-qm", "fixture") + self.config = ( + 'CONFIG_IDF_TARGET="esp32s3"\nCONFIG_OPENCLAW_ROOM_WIFI_PASSWORD=""\n' + 'CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp_large.csv"\n' + ) + (self.project / "sdkconfig.defaults").write_text("CONFIG_LOG_DEFAULT_LEVEL_INFO=y\n") + (self.project / "sdkconfig.defaults.esp32s3").write_text("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y\n") + # Contract fields from ESP-IDF v5.5.5 tools/cmake/project_description.json.in + # and components/esptool_py/flasher_args.json.in, not a captured device build. + self.description = { + "version": "1.2", + "project_name": "test_node", + "project_version": "1.0.0", + "project_path": str(self.project), + "build_dir": str(self.build), + "config_file": str(self.project / "sdkconfig"), + "config_defaults": str(self.project / "sdkconfig.defaults"), + "idf_path": str(self.idf), + "git_revision": "v5.5.5", + "target": "esp32s3", + "min_rev": "0", + "max_rev": "199", + "monitor_baud": "115200", + "app_bin": "app.bin", + "build_components": ["main", "esp-sr"], + } + self.flash = { + "write_flash_args": ["--flash_mode", "dio", "--flash_size", "16MB", "--flash_freq", "80m"], + "flash_settings": {"flash_mode": "dio", "flash_size": "16MB", "flash_freq": "80m"}, + "extra_esptool_args": { + "chip": "esp32s3", "before": "default_reset", "after": "hard_reset", "stub": True, + }, + "flash_files": { + "0x0": "bootloader/bootloader.bin", + "0x8000": "partition_table/partition-table.bin", + "0x10000": "app.bin", + "0x810000": "../managed_components/speech models/model.bin", + }, + } + for name, offset in (("bootloader", "0x0"), ("partition-table", "0x8000"), ("app", "0x10000"), ("model", "0x810000")): + filename = self.flash["flash_files"][offset] + self.flash[name] = {"offset": offset, "file": filename, "encrypted": "false"} + path = self.build / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((name + "-fixture").encode()) + self.dependencies = { + "version": "2.0.0", + "target": "esp32s3", + "dependencies": { + "espressif/example": { + "version": "1.2.3", "component_hash": "a" * 64, + "source": {"type": "service", "service_url": "https://api.components.espressif.com/"}, + }, + "local": {"version": "*", "source": {"type": "local", "path": str(self.project)}}, + }, + } + self.provenance = { + "repository": "openclaw/esp-openclaw-node", "idf_image": "espressif/idf:release-v5.5", + "event": "pull_request", "event_sha": "1" * 40, "pr_head_sha": "2" * 40, + "run_id": "123", "run_attempt": "2", + } + + def git(self, repo, *arguments): + return subprocess.check_output( + ["git", "-C", str(repo), *arguments], text=True, stderr=subprocess.PIPE + ).strip() + + def write_metadata(self): + (self.project / "sdkconfig").write_text(self.config) + (self.project / "dependencies.lock").write_text(json.dumps(self.dependencies)) + (self.build / "project_description.json").write_text(json.dumps(self.description)) + (self.build / "flasher_args.json").write_text(json.dumps(self.flash)) + + def package(self): + self.write_metadata() + return firmware.package_firmware( + self.project, self.build, self.output, self.description["target"], + self.provenance, self.dependencies, + ) + + def test_relocated_bundle_preserves_every_image_and_original_inputs(self): + self.write_metadata() + originals = {path: path.read_bytes() for path in self.project.rglob("*") if path.is_file()} + self.package() + for path, content in originals.items(): + self.assertEqual(path.read_bytes(), content) + relocated = self.root / "extracted bundle" + shutil.copytree(self.output, relocated) + metadata = json.loads((relocated / "flasher_args.json").read_text()) + self.assertEqual(set(metadata["flash_files"]), set(self.flash["flash_files"])) + for offset, filename in metadata["flash_files"].items(): + self.assertEqual( + (relocated / filename).read_bytes(), + (self.build / self.flash["flash_files"][offset]).read_bytes(), + ) + self.assertEqual(metadata["model"]["file"], metadata["flash_files"]["0x810000"]) + arguments = shlex.split((relocated / "flash_args").read_text()) + self.assertEqual(arguments[:6], self.flash["write_flash_args"]) + self.assertEqual(dict(zip(arguments[6::2], arguments[7::2])), metadata["flash_files"]) + checksummed = set() + for line in (relocated / "SHA256SUMS").read_text().splitlines(): + digest, name = line.split(" ", 1) + self.assertEqual(hashlib.sha256((relocated / name).read_bytes()).hexdigest(), digest) + checksummed.add(name) + self.assertEqual(checksummed, { + path.relative_to(relocated).as_posix() for path in relocated.rglob("*") + if path.is_file() and path.name != "SHA256SUMS" + }) + manifest = json.loads((relocated / "manifest.json").read_text()) + self.assertEqual(manifest["source"]["commit"], self.git(self.repo, "rev-parse", "HEAD")) + self.assertEqual(manifest["idf"]["commit"], self.git(self.idf, "rev-parse", "HEAD")) + self.assertEqual(manifest["idf"]["build_revision"], "v5.5.5") + self.assertEqual(manifest["ci"]["pr_head_sha"], "2" * 40) + self.assertEqual(manifest["ci"]["event_sha"], "1" * 40) + inputs = manifest["configuration_inputs"] + self.assertEqual([item["kind"] for item in inputs], [ + "defaults", "target_defaults", "partition_table", + ]) + self.assertEqual(inputs[1]["sha256"], firmware.sha256(self.project / "sdkconfig.defaults.esp32s3")) + self.assertEqual(inputs[2]["path"], "/components/partition_table/partitions_singleapp_large.csv") + for path in relocated.glob("*"): + if path.is_file(): + self.assertNotIn(str(self.root), path.read_text()) + for name, record in manifest["files"].items(): + self.assertEqual(record["sha256"], firmware.sha256(relocated / name)) + self.assertEqual(record["size"], (relocated / name).stat().st_size) + (relocated / metadata["app"]["file"]).write_bytes(b"damaged") + self.assertNotEqual( + firmware.sha256(relocated / metadata["app"]["file"]), + manifest["files"][metadata["app"]["file"]]["sha256"], + ) + + def test_invalid_inputs_never_leave_uploadable_output(self): + cases = ( + ("missing", lambda: (self.build / "app.bin").unlink()), + ("escape", lambda: self.flash["flash_files"].update({"0x900000": str(self.root / "outside.bin")})), + ("target", lambda: self.flash["extra_esptool_args"].update(chip="esp32p4")), + ("config target", lambda: setattr(self, "config", 'CONFIG_IDF_TARGET="esp32p4"\n')), + ("lock target", lambda: self.dependencies.update(target="esp32p4")), + ("encrypted", lambda: self.flash["app"].update(encrypted="true")), + ("signed config", lambda: setattr(self, "config", self.config + "CONFIG_SECURE_BOOT=y\n")), + ("credentials", lambda: setattr(self, "config", self.config + 'CONFIG_OPENCLAW_ROOM_SETUP_CODE="synthetic-secret"\n')), + ("overlap", lambda: self.flash["flash_files"].update({"0x1": "app.bin"})), + ("named entry", lambda: self.flash["app"].update(file="other.bin")), + ("unsupported args", lambda: self.flash["write_flash_args"].append("--force")), + ("dirty source", lambda: (self.repo / ".gitignore").write_text("changed\n")), + ) + baseline = copy.deepcopy((self.flash, self.config, self.dependencies)) + (self.root / "outside.bin").write_bytes(b"outside") + for name, change in cases: + with self.subTest(name=name): + self.flash, self.config, self.dependencies = copy.deepcopy(baseline) + (self.build / "app.bin").write_bytes(b"app-fixture") + (self.repo / ".gitignore").write_text("examples/\n") + change() + with self.assertRaises((ValueError, FileNotFoundError)): + self.package() + self.assertFalse(self.output.exists()) + + def test_symlink_escape_is_rejected(self): + external = self.root / "outside.bin" + external.write_bytes(b"outside") + (self.build / "app.bin").unlink() + (self.build / "app.bin").symlink_to(external) + with self.assertRaises(ValueError): + self.package() + self.assertFalse(self.output.exists()) + + def test_in_root_symlink_is_relocated_as_a_regular_image(self): + (self.build / "app.bin").rename(self.build / "actual.bin") + (self.build / "app.bin").symlink_to("actual.bin") + self.package() + image = self.output / "images/00010000.bin" + self.assertFalse(image.is_symlink()) + self.assertEqual(image.read_bytes(), b"app-fixture") + + @unittest.skipUnless(HAS_YAML, "real lock reader runs in the existing IDF environment") + def test_lock_reader_preserves_yaml_dependency_identity(self): + lock = self.project / "dependencies.lock" + lock.write_text( + "# IDF component-manager 2.x lock schema\n" + "version: 2.0.0\ntarget: esp32s3\nmanifest_hash: abcdef\n" + "dependencies:\n" + " espressif/example:\n" + " version: 1.2.3\n" + " component_hash: abcdef\n" + " source:\n" + " type: service\n" + " service_url: https://api.components.espressif.com/\n" + " local:\n" + " version: '*'\n" + " source:\n" + " type: local\n" + " path: ../../components/local\n" + ) + original = lock.read_bytes() + dependencies = firmware.read_dependency_lock(lock) + self.assertEqual(dependencies["target"], "esp32s3") + self.assertEqual(dependencies["dependencies"]["espressif/example"]["version"], "1.2.3") + self.assertEqual(dependencies["dependencies"]["local"]["source"]["path"], "../../components/local") + self.assertEqual(lock.read_bytes(), original) + lock.write_text("- not-a-lock\n") + with self.assertRaises(ValueError): + firmware.read_dependency_lock(lock) + + def test_reset_and_no_stub_settings_reach_flash_command(self): + self.flash["extra_esptool_args"].update(stub=False, before="no_reset", after="no_reset") + self.package() + instructions = (self.output / "FLASHING.md").read_text() + self.assertIn("--before no_reset --after no_reset --no-stub", instructions) + self.assertIn('write_flash "@flash_args"', instructions) + + def test_metadata_sanitization_keeps_dependency_identity(self): + source = self.dependencies["dependencies"]["local"]["source"] + source["path"] = "/private/build-owner/components/example" + source["url"] = "https://fixture:synthetic-secret@github.com/example/repo" + self.config += 'CONFIG_EXAMPLE_PATH="/private/build-owner/config"\n' + self.package() + text = (self.output / "dependencies.lock.sanitized.json").read_text() + self.assertNotIn("build-owner", text) + self.assertNotIn("synthetic-secret", text) + resolved = json.loads(text)["dependencies"]["espressif/example"] + self.assertEqual(resolved, self.dependencies["dependencies"]["espressif/example"]) + + def test_pinned_submodule_commit_is_recorded(self): + module = self.repo / "third_party/sdk" + module.parent.mkdir() + self.git(self.repo, "-c", "protocol.file.allow=always", "submodule", "add", "-q", str(self.idf), "third_party/sdk") + self.git(self.repo, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", + "-c", "commit.gpgsign=false", "commit", "-qam", "submodule fixture") + self.package() + source = json.loads((self.output / "manifest.json").read_text())["source"] + self.assertEqual(source["submodules"], [ + {"path": "third_party/sdk", "commit": self.git(self.idf, "rev-parse", "HEAD")} + ]) + + def test_tab5_records_upstream_and_local_bridge_identity(self): + destination = self.repo / "examples/m5stack-tab5-room-node" + self.project.rename(destination) + self.project = destination + self.build = destination / "build" + self.output = self.build / "firmware" + self.description.update( + target="esp32p4", project_path=str(destination), build_dir=str(self.build), + config_file=str(destination / "sdkconfig"), + config_defaults=str(destination / "sdkconfig.defaults"), + ) + self.dependencies["target"] = "esp32p4" + self.config = ( + 'CONFIG_IDF_TARGET="esp32p4"\nCONFIG_PARTITION_TABLE_CUSTOM=y\n' + 'CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"\n' + ) + (destination / "partitions.csv").write_text("factory,app,factory,0x10000,8M,\n") + self.flash["extra_esptool_args"]["chip"] = "esp32p4" + bridge = destination / "components/m5stack_tab5/CMakeLists.txt" + bridge.parent.mkdir(parents=True) + bridge.write_text( + "FetchContent_Declare(tab5_bsp_source\n" + f" URL https://github.com/espressif/esp-bsp/archive/{'a' * 40}.tar.gz\n" + f" URL_HASH SHA256={'b' * 64})\n" + ) + (self.build / "CMakeCache.txt").write_text("OPENCLAW_TAB5_BSP_LOCAL_PATH:PATH=\n") + self.package() + manifest = json.loads((self.output / "manifest.json").read_text()) + self.assertEqual(manifest["tab5_bsp"]["upstream_commit"], "a" * 40) + self.assertEqual(manifest["tab5_bsp"]["upstream_archive_sha256"], "b" * 64) + self.assertEqual(manifest["tab5_bsp"]["bridge_sha256"], firmware.sha256(bridge)) + self.assertIn("not an unmodified upstream", manifest["tab5_bsp"]["integration"]) + self.assertEqual(manifest["configuration_inputs"][-1], { + "kind": "partition_table", "path": "/partitions.csv", + "sha256": firmware.sha256(destination / "partitions.csv"), + }) + + +if __name__ == "__main__": + unittest.main() From c7c7a0c8cb2178ceba41af03e4b3a8c10f3b6f8a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 8 Sep 2026 01:24:35 +0800 Subject: [PATCH 2/3] feat: add a static Tab5 node home --- .github/workflows/ci.yml | 3 +- .../esp-openclaw-room-node/CMakeLists.txt | 1 + components/esp-openclaw-room-node/README.md | 18 +- .../esp-openclaw-room-node/assets/NOTICE.md | 40 +++ .../assets/openclaw_lobster.argb8888 | Bin 0 -> 129600 bytes .../esp_openclaw_room_node.c | 110 ++++++- .../include/esp_openclaw_room_node.h | 1 + .../esp-openclaw-room-node/room_board.c | 3 +- .../room_ui_controller.c | 168 +++++++++-- .../room_ui_controller.h | 36 +++ .../esp-openclaw-room-node/tests/README.md | 26 ++ .../tests/host/esp_event.h | 7 + .../tests/host/esp_wifi.h | 4 + .../tests/host/room_host_fakes.c | 16 +- .../tests/host/room_host_fakes.h | 4 + .../tests/run_ui_host_tests.py | 105 +++++++ .../tests/test_room_audio_port_compat.py | 20 +- .../tests/test_room_talk_lifecycle.c | 60 ++++ .../tests/test_room_ui_controller.c | 285 ++++++++++++++++++ examples/m5stack-tab5-room-node/README.md | 8 + .../tab5_room_board/tab5_room_board.c | 3 +- 21 files changed, 871 insertions(+), 47 deletions(-) create mode 100644 components/esp-openclaw-room-node/assets/NOTICE.md create mode 100644 components/esp-openclaw-room-node/assets/openclaw_lobster.argb8888 create mode 100644 components/esp-openclaw-room-node/tests/host/esp_wifi.h create mode 100644 components/esp-openclaw-room-node/tests/run_ui_host_tests.py create mode 100644 components/esp-openclaw-room-node/tests/test_room_ui_controller.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7deefc..9efa632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,8 @@ jobs: python3 -m unittest discover -s ../../../../scripts/tests -p test_package_firmware.py -k lock_reader -v fi && if [ "${{ matrix.name }}" = "waveshare-amoled-room-node" ]; then - python3 ../../components/esp-openclaw-room-node/tests/run_lifecycle_host_tests.py --managed-components managed_components + python3 ../../components/esp-openclaw-room-node/tests/run_lifecycle_host_tests.py --managed-components managed_components && + python3 ../../components/esp-openclaw-room-node/tests/run_ui_host_tests.py --lvgl-dir managed_components/lvgl__lvgl fi && if [ "${{ matrix.name }}" != "component-test-app-build" ]; then python3 ../../scripts/package_firmware.py \ diff --git a/components/esp-openclaw-room-node/CMakeLists.txt b/components/esp-openclaw-room-node/CMakeLists.txt index 4b41fba..c161aa2 100644 --- a/components/esp-openclaw-room-node/CMakeLists.txt +++ b/components/esp-openclaw-room-node/CMakeLists.txt @@ -21,6 +21,7 @@ idf_component_register( "room_ui_controller.c" INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS "." + EMBED_FILES "assets/openclaw_lobster.argb8888" REQUIRES av_render console diff --git a/components/esp-openclaw-room-node/README.md b/components/esp-openclaw-room-node/README.md index ff35c3f..a96174f 100644 --- a/components/esp-openclaw-room-node/README.md +++ b/components/esp-openclaw-room-node/README.md @@ -10,6 +10,15 @@ storage port exposes an approved file root. Codec models, pins, panel controllers, remote-Wi-Fi transport, and scheduler profiles remain outside this component. +Non-animated displays use a static OpenClaw home with board identity, separate +Wi-Fi/Gateway/Talk facts, and visible error details. Canvas and Diagnostics +cover the home without replacing those facts. The optional trailing +`display.idle_brightness` field accepts 0-100; omitted or zero preserves idle +display sleep, and explicit off requests remain unchanged. A nonzero value +keeps the backlight dimly lit and consumes more idle power. The home is a +status surface, not evidence that touch, camera, networking or Talk has been +qualified on a particular board. + The audio port's input gain override is optional. Boards that set `configure_input_gain` also provide `input_gain_db`; otherwise shared media initialization preserves the codec or board default. @@ -31,12 +40,13 @@ by the current media stack: WakeNet only advances while its AFE fetch path is drained. It lives here once, alongside the shared capture orchestration, until the upstream capture component exposes the equivalent wake callback contract. The closure retains Espressif's modified-MIT notice in -`LICENSE.ESPRESSIF-MODIFIED-MIT`; the rest of this component is Apache-2.0. +`LICENSE.ESPRESSIF-MODIFIED-MIT`. The OpenClaw home image retains its MIT notice +and source provenance in [assets/NOTICE.md](assets/NOTICE.md); the remaining +component source is Apache-2.0. Long-press the status screen or an empty Canvas background to open the shared -Diagnostics overlay. Non-animated status screens show `Hold for diagnostics` -near the bottom; the hint is hidden on Canvas and while the modal is open. The -overlay keeps the current screen loaded beneath a blocking, scrollable modal; +Diagnostics overlay. The overlay keeps the current screen loaded beneath a +blocking, scrollable modal; tap the large Close button (or long-press the modal) to return. Audio is shown first: live MIC, post-AFE, and RX/SPK PCM meters include freshness, counters, capture ownership, AFE/WakeNet mode, and renderer results. diff --git a/components/esp-openclaw-room-node/assets/NOTICE.md b/components/esp-openclaw-room-node/assets/NOTICE.md new file mode 100644 index 0000000..118641d --- /dev/null +++ b/components/esp-openclaw-room-node/assets/NOTICE.md @@ -0,0 +1,40 @@ +# OpenClaw Home Image + +`openclaw_lobster.argb8888` is the existing transparent 180 x 180 OpenClaw +`ui/public/apple-touch-icon.png`, converted without resizing to straight-alpha +BGRA bytes for LVGL's little-endian ARGB8888 format. It contains exactly 129600 +bytes and is embedded as read-only firmware data. No PNG decoder, image +animation, or per-frame image allocation is used. + +Source: https://github.com/openclaw/openclaw/blob/56cd7472a3ce190e866fbe67838fc45705e6b2cd/ui/public/apple-touch-icon.png + +License: https://github.com/openclaw/openclaw/blob/56cd7472a3ce190e866fbe67838fc45705e6b2cd/LICENSE + +Conversion uses Pillow 11.3.0: `Image.open(source).convert("RGBA").tobytes("raw", "BGRA")`. + +SHA-256: + +- Source PNG: `e8c7ce0a3a6c52bd904cc55e31a5b3a8b6392dcd70ba9e220ecf0ef1d6bd8c16` +- Embedded bitmap: `984065fcee3b54174a3849c56ba5c31c890ee2bca35124e88ce287255aa58044` + +## MIT License + +Copyright (c) 2026 OpenClaw Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/components/esp-openclaw-room-node/assets/openclaw_lobster.argb8888 b/components/esp-openclaw-room-node/assets/openclaw_lobster.argb8888 new file mode 100644 index 0000000000000000000000000000000000000000..41e01a7bd97d2dd766bc60fdd413a1da02a8c95d GIT binary patch literal 129600 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 W19}GZ4CooqGoWWc&p^J-!2bjB=)p+< literal 0 HcmV?d00001 diff --git a/components/esp-openclaw-room-node/esp_openclaw_room_node.c b/components/esp-openclaw-room-node/esp_openclaw_room_node.c index 2bb8d02..6414aa8 100644 --- a/components/esp-openclaw-room-node/esp_openclaw_room_node.c +++ b/components/esp-openclaw-room-node/esp_openclaw_room_node.c @@ -17,6 +17,7 @@ #include "esp_peer.h" #include "esp_peer_default.h" #include "esp_timer.h" +#include "esp_wifi.h" #include "esp_webrtc.h" #include "esp_capture.h" #include "freertos/idf_additions.h" @@ -50,6 +51,11 @@ static TimerHandle_t talk_timeout_timer; static bool operator_ready; static bool node_ready; static bool media_ready; +static bool media_initialized; +static bool node_session_missing; +static bool operator_session_missing; +static bool node_connection_pending; +static room_ui_wifi_state_t home_wifi_state; static bool talk_start_in_flight; static bool talk_dialing; static bool talk_active; @@ -75,6 +81,56 @@ static char *gateway_http_base; * generation-checked facts under state_lock are the only work authority. */ typedef uint8_t talk_teardown_request_t; +static void refresh_home_facts(void) +{ + xSemaphoreTake(state_lock, portMAX_DELAY); + const room_ui_facts_t facts = { + .wifi = home_wifi_state, + .gateway = node_client == NULL ? ROOM_UI_GATEWAY_STARTING + : node_ready ? ROOM_UI_GATEWAY_CONNECTED + : node_session_missing ? ROOM_UI_GATEWAY_NO_SESSION + : node_connection_pending ? ROOM_UI_GATEWAY_CONNECTING : ROOM_UI_GATEWAY_OFFLINE, + .talk = !media_initialized ? ROOM_UI_TALK_STARTING + : !media_ready ? ROOM_UI_TALK_UNAVAILABLE + : talk_call != NULL && (talk_cancel_requested || talk_closing) ? ROOM_UI_TALK_STOPPING + : talk_active ? ROOM_UI_TALK_ACTIVE + : talk_call != NULL ? ROOM_UI_TALK_CONNECTING + : operator_ready ? ROOM_UI_TALK_READY + : operator_session_missing ? ROOM_UI_TALK_NO_SESSION : ROOM_UI_TALK_WAITING, + }; + /* Store while the owner is locked, but never wait for LVGL under that lock. */ + room_ui_store_facts(&facts); + xSemaphoreGive(state_lock); + room_ui_refresh(); +} + +static void refresh_wifi_facts(esp_event_base_t base, int32_t event_id) +{ + esp_openclaw_node_wifi_status_t wifi = {0}; + esp_openclaw_node_wifi_get_status(&wifi); + bool offline = (base == WIFI_EVENT && + (event_id == WIFI_EVENT_STA_DISCONNECTED || event_id == WIFI_EVENT_STA_STOP)) || + (base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP); + xSemaphoreTake(state_lock, portMAX_DELAY); + home_wifi_state = wifi.connected && !offline ? ROOM_UI_WIFI_CONNECTED + : !wifi.has_saved_network ? ROOM_UI_WIFI_UNCONFIGURED + : offline ? ROOM_UI_WIFI_OFFLINE : ROOM_UI_WIFI_CONNECTING; + xSemaphoreGive(state_lock); + refresh_home_facts(); +} + +static void home_network_event(void *arg, esp_event_base_t base, int32_t event_id, void *data) +{ + (void)arg; + (void)data; + if ((base == WIFI_EVENT && (event_id == WIFI_EVENT_STA_START || + event_id == WIFI_EVENT_STA_CONNECTED || event_id == WIFI_EVENT_STA_DISCONNECTED || + event_id == WIFI_EVENT_STA_STOP)) || + (base == IP_EVENT && (event_id == IP_EVENT_STA_GOT_IP || event_id == IP_EVENT_STA_LOST_IP))) { + refresh_wifi_facts(base, event_id); + } +} + static void media_scheduler(const char *name, media_lib_thread_cfg_t *cfg) { if (strcmp(name, "aenc_0") == 0 || strcmp(name, "AUD_SRC") == 0) { @@ -280,6 +336,7 @@ static void talk_teardown_task(void *arg) esp_openclaw_talk_call_handle_t call = talk_call; const char *message = talk_cancel_message; xSemaphoreGive(state_lock); + refresh_home_facts(); /* One worker serializes call/operator UI. A delayed callback never * paints over a replacement call's newer connecting/speaking state. */ if (speaking) room_ui_set(ROOM_UI_SPEAKING, NULL); @@ -318,6 +375,7 @@ static void talk_teardown_task(void *arg) talk_closing = false; xSemaphoreGive(state_lock); esp_openclaw_talk_call_release(call); + refresh_home_facts(); } } @@ -513,6 +571,7 @@ static void operator_event( if (current) { operator_ready = connected; if (connected) { + operator_session_missing = false; ++operator_incarnation; if (client == talk_operator && talk_operator_incarnation != operator_incarnation) { request_talk_stop_locked(talk_generation, NULL); @@ -535,6 +594,14 @@ static void operator_event( } } +static void record_operator_session_result(esp_err_t err) +{ + if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) return; + xSemaphoreTake(state_lock, portMAX_DELAY); + operator_session_missing = err == ESP_ERR_NOT_FOUND; + xSemaphoreGive(state_lock); +} + static esp_err_t start_operator_client(void) { xSemaphoreTake(state_lock, portMAX_DELAY); @@ -549,6 +616,7 @@ static esp_err_t start_operator_client(void) .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION, }; esp_err_t err = esp_openclaw_node_request_connect(existing, &reconnect); + record_operator_session_result(err); return err == ESP_ERR_INVALID_STATE ? ESP_OK : err; } @@ -576,7 +644,9 @@ static esp_err_t start_operator_client(void) xSemaphoreGive(state_lock); /* The canvas keep-warm refresh needs operator scope. */ room_canvas_set_refresh_client(created); - return esp_openclaw_node_request_connect(created, &request); + err = esp_openclaw_node_request_connect(created, &request); + record_operator_session_result(err); + return err; } static void operator_start_task(void *arg) @@ -588,6 +658,7 @@ static void operator_start_task(void *arg) operator_start_scheduled = false; xSemaphoreGive(state_lock); esp_err_t err = start_operator_client(); + refresh_home_facts(); if (err != ESP_OK) { // Every retry logs: the display alone cannot say WHY the operator // session is down, and a silent loop here cost a debugging session. @@ -609,15 +680,17 @@ static esp_err_t request_node_connection(void) .source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION, }; esp_err_t err = esp_openclaw_node_request_connect(node_client, &request); - if (err != ESP_ERR_NOT_FOUND) { - return err; + if (err == ESP_ERR_NOT_FOUND && CONFIG_OPENCLAW_ROOM_SETUP_CODE[0] != '\0') { + request.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE; + request.value = CONFIG_OPENCLAW_ROOM_SETUP_CODE; + err = esp_openclaw_node_request_connect(node_client, &request); } - if (CONFIG_OPENCLAW_ROOM_SETUP_CODE[0] == '\0') { - return ESP_ERR_NOT_FOUND; - } - request.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE; - request.value = CONFIG_OPENCLAW_ROOM_SETUP_CODE; - return esp_openclaw_node_request_connect(node_client, &request); + xSemaphoreTake(state_lock, portMAX_DELAY); + if (err == ESP_OK || err == ESP_ERR_NOT_FOUND) node_session_missing = err == ESP_ERR_NOT_FOUND; + node_connection_pending = err == ESP_OK; + xSemaphoreGive(state_lock); + refresh_home_facts(); + return err; } static void schedule_node_reconnect(uint32_t delay_ms); @@ -676,7 +749,10 @@ static void node_event( (void)ctx; xSemaphoreTake(state_lock, portMAX_DELAY); node_ready = event == ESP_OPENCLAW_NODE_EVENT_CONNECTED; + node_connection_pending = false; + if (node_ready) node_session_missing = false; xSemaphoreGive(state_lock); + refresh_home_facts(); if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) { /* * The session URI lands in NVS only after the first hello-ok, so the @@ -733,6 +809,7 @@ static void node_event( host += 6; } room_ui_set_gateway(host); + refresh_home_facts(); free(gateway_uri); /* The timer path retries task creation itself, so scheduling only * fails on timer-create OOM at boot; retry through the same path. */ @@ -1237,11 +1314,18 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co if (config->services.prepare_network != NULL) { esp_err_t network_err = config->services.prepare_network(config->services.ctx); if (network_err != ESP_OK) { + xSemaphoreTake(state_lock, portMAX_DELAY); + home_wifi_state = ROOM_UI_WIFI_UNAVAILABLE; + xSemaphoreGive(state_lock); + refresh_home_facts(); room_ui_set(ROOM_UI_ERROR, "Wi-Fi coprocessor unavailable"); return network_err; } } ESP_ERROR_CHECK(esp_openclaw_node_wifi_start()); + /* Register after the station helper so its status reflects each event first. */ + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, home_network_event, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, home_network_event, NULL)); ESP_LOGI(TAG, "Wi-Fi runtime started"); seed_wifi_credentials_from_kconfig(); esp_openclaw_node_wifi_status_t wifi_status = {0}; @@ -1255,6 +1339,7 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co !esp_openclaw_node_wifi_wait_for_connection(pdMS_TO_TICKS(30000))) { ESP_LOGW(TAG, "Wi-Fi did not connect within 30 s; fix credentials over the USB console"); } + refresh_wifi_facts(NULL, 0); ESP_LOGI(TAG, "initializing room media"); esp_err_t media_err = room_media_init(on_wake, NULL); @@ -1265,9 +1350,12 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co TAG, "room media init failed: %s; Talk wake is disabled", esp_err_to_name(media_err)); - } else { - media_ready = true; } + xSemaphoreTake(state_lock, portMAX_DELAY); + media_ready = media_err == ESP_OK; + media_initialized = true; + xSemaphoreGive(state_lock); + refresh_home_facts(); ESP_ERROR_CHECK(start_node_client()); ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(node_client)); diff --git a/components/esp-openclaw-room-node/include/esp_openclaw_room_node.h b/components/esp-openclaw-room-node/include/esp_openclaw_room_node.h index e548905..c41a03b 100644 --- a/components/esp-openclaw-room-node/include/esp_openclaw_room_node.h +++ b/components/esp-openclaw-room-node/include/esp_openclaw_room_node.h @@ -27,6 +27,7 @@ typedef struct { bool animated_face; /**< Enable the procedural face when the display pipeline can sustain it. */ uint16_t animation_frame_ms; /**< Sustainable face cadence for this display pipeline; 0 uses 16 ms. */ void *ctx; + uint8_t idle_brightness; /**< Idle backlight percentage, 0..100; omitted/0 preserves display sleep. */ } esp_openclaw_room_display_port_t; typedef struct { diff --git a/components/esp-openclaw-room-node/room_board.c b/components/esp-openclaw-room-node/room_board.c index 0e26e45..f7d8031 100644 --- a/components/esp-openclaw-room-node/room_board.c +++ b/components/esp-openclaw-room-node/room_board.c @@ -10,7 +10,8 @@ esp_err_t room_board_bind(const esp_openclaw_room_node_config_t *config) if (config == NULL || config->display.start == NULL || config->display.lock == NULL || config->display.unlock == NULL || config->display.set_brightness == NULL || config->audio.open == NULL || - config->audio.afe_layout == NULL || config->audio.record_channels == 0) { + config->audio.afe_layout == NULL || config->audio.record_channels == 0 || + config->display.idle_brightness > 100) { return ESP_ERR_INVALID_ARG; } board = *config; diff --git a/components/esp-openclaw-room-node/room_ui_controller.c b/components/esp-openclaw-room-node/room_ui_controller.c index e01b8f0..823ce6e 100644 --- a/components/esp-openclaw-room-node/room_ui_controller.c +++ b/components/esp-openclaw-room-node/room_ui_controller.c @@ -29,11 +29,16 @@ static const char *TAG = "room_ui"; #define ROOM_UI_CAMERA_MIN_BRIGHTNESS 40 static lv_obj_t *status_label; static lv_obj_t *gateway_label; -static lv_obj_t *diagnostics_hint_label; +static lv_obj_t *home; +static lv_obj_t *home_wifi; +static lv_obj_t *home_gateway; +static lv_obj_t *home_talk; +static lv_obj_t *home_error; static lv_obj_t *talk_pill; static lv_obj_t *talk_pill_label; static lv_obj_t *camera_indicator; static bool animated_face_enabled; +static uint8_t idle_brightness; /* Guarded by state_mux like the state/detail facts. */ static char gateway_text[64]; /* Last state/detail survive canvas mode so leaving it restores the live Talk @@ -46,8 +51,108 @@ static room_ui_state_t current_state = ROOM_UI_IDLE; static char current_detail[48]; static bool diagnostics_open; static bool text_hint_active; +static bool camera_indicator_active; +static room_ui_facts_t current_facts; static esp_timer_handle_t text_hint_timer; +extern const uint8_t home_lobster_pixels[] asm("_binary_openclaw_lobster_argb8888_start"); +static const lv_image_dsc_t home_lobster = { + .header = { + .magic = LV_IMAGE_HEADER_MAGIC, + .cf = LV_COLOR_FORMAT_ARGB8888, + .w = 180, + .h = 180, + .stride = 180 * 4, + }, + .data_size = 180 * 180 * 4, + .data = home_lobster_pixels, +}; + +static lv_obj_t *home_label(const char *text, const lv_font_t *font, uint32_t color) +{ + lv_obj_t *label = lv_label_create(home); + lv_obj_set_width(label, LV_PCT(100)); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_label_set_text_static(label, text); + lv_obj_set_style_text_font(label, font, 0); + lv_obj_set_style_text_color(label, lv_color_hex(color), 0); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_clear_flag(label, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); + return label; +} + +static void home_create(lv_display_t *display, const esp_openclaw_room_node_config_t *board) +{ + int width = lv_display_get_horizontal_resolution(display) - 2 * board->display.safe_inset; + home = lv_obj_create(lv_screen_active()); + lv_obj_set_width(home, width < 600 ? width : 600); + lv_obj_set_height(home, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(home, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(home, 0, 0); + lv_obj_set_style_radius(home, 0, 0); + lv_obj_set_style_pad_all(home, 0, 0); + lv_obj_set_style_pad_row(home, 12, 0); + lv_obj_set_flex_flow(home, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(home, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(home, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); + lv_obj_center(home); + + lv_obj_t *image = lv_image_create(home); + lv_image_set_src(image, &home_lobster); + lv_obj_clear_flag(image, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE); +#if LV_FONT_MONTSERRAT_28 + home_label("OpenClaw Room Node", &lv_font_montserrat_28, 0xffffff); +#else + home_label("OpenClaw Room Node", &lv_font_montserrat_14, 0xffffff); +#endif + home_label(board->display_name != NULL ? board->display_name : "Room node", + &lv_font_montserrat_14, 0x9aabaa); +#if LV_FONT_MONTSERRAT_20 + const lv_font_t *font = &lv_font_montserrat_20; +#else + const lv_font_t *font = &lv_font_montserrat_14; +#endif + home_wifi = home_label("Wi-Fi Starting", font, 0x9aabaa); + home_gateway = home_label("Gateway Starting", font, 0x9aabaa); + home_talk = home_label("Talk Starting", font, 0x9aabaa); + home_error = home_label("", &lv_font_montserrat_14, 0xff8383); + lv_obj_set_height(home_error, 48); +} + +static void home_render(const room_ui_facts_t *facts, room_ui_state_t state, const char *detail) +{ + static const char *wifi[] = { + "Wi-Fi Starting", "Wi-Fi Not configured", "Wi-Fi Connecting", + "Wi-Fi Connected", "Wi-Fi Offline", "Wi-Fi Unavailable", + }; + static const char *gateway[] = { + "Gateway Starting", "Gateway Connecting", "Gateway Connected", + "Gateway Offline", "Gateway Pairing required", + }; + static const char *talk[] = { + "Talk Starting", "Talk Unavailable", "Talk Waiting for operator", + "Talk Operator session required", "Talk Ready", "Talk Connecting", + "Talk Active", "Talk Stopping", + }; + lv_label_set_text_static(home_wifi, + (unsigned)facts->wifi < sizeof(wifi) / sizeof(*wifi) ? wifi[facts->wifi] : "Wi-Fi Unknown"); + lv_label_set_text_static(home_gateway, + (unsigned)facts->gateway < sizeof(gateway) / sizeof(*gateway) ? gateway[facts->gateway] : "Gateway Unknown"); + lv_label_set_text_static(home_talk, + (unsigned)facts->talk < sizeof(talk) / sizeof(*talk) ? talk[facts->talk] : "Talk Unknown"); + lv_obj_set_style_text_color(home_wifi, + lv_color_hex(facts->wifi == ROOM_UI_WIFI_CONNECTED ? 0x54d6af : 0x9aabaa), 0); + lv_obj_set_style_text_color(home_gateway, + lv_color_hex(facts->gateway == ROOM_UI_GATEWAY_CONNECTED ? 0x54d6af : 0x9aabaa), 0); + lv_obj_set_style_text_color(home_talk, + lv_color_hex(facts->talk == ROOM_UI_TALK_ACTIVE ? 0xf4c16b + : facts->talk == ROOM_UI_TALK_READY ? 0x54d6af : 0x9aabaa), 0); + const char *error = state == ROOM_UI_ERROR ? (detail[0] != '\0' ? detail : "Error") : ""; + if (strcmp(lv_label_get_text(home_error), error) != 0) { + lv_label_set_text(home_error, error); + } +} + static void repaint_retry_expired(void *arg); static void text_hint_expired(void *arg) @@ -144,21 +249,15 @@ void room_ui_init(void) NULL); const esp_openclaw_room_node_config_t *board = room_board_config(); bool board_has_animated_face = board != NULL && board->display.animated_face; + idle_brightness = board != NULL ? board->display.idle_brightness : 0; animated_face_enabled = board_has_animated_face; if (animated_face_enabled && room_face_create(lv_screen_active()) != ESP_OK) { animated_face_enabled = false; ESP_LOGW(TAG, "face unavailable; talk states fall back to text"); } - if (!board_has_animated_face) { - diagnostics_hint_label = lv_label_create(lv_screen_active()); - lv_obj_set_style_text_color(diagnostics_hint_label, lv_color_hex(0x707070), 0); - lv_obj_set_style_text_font(diagnostics_hint_label, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_align(diagnostics_hint_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(diagnostics_hint_label, LV_ALIGN_BOTTOM_MID, 0, -24); - lv_label_set_text(diagnostics_hint_label, "Hold for diagnostics"); - } + if (board != NULL && !board_has_animated_face) home_create(display, board); room_board_display_unlock(); - room_board_display_brightness_set(0); + room_ui_refresh(); } /* Talk-driven states render as the animated face; text is for setup/errors. */ @@ -174,11 +273,13 @@ static int room_ui_target_brightness(room_ui_state_t state, bool canvas_active) taskENTER_CRITICAL(&state_mux); bool overlay = diagnostics_open; bool text_hint = text_hint_active; + bool camera = camera_indicator_active; taskEXIT_CRITICAL(&state_mux); - if (overlay) return ROOM_CANVAS_ACTIVE_BRIGHTNESS; - if (canvas_active) return ROOM_CANVAS_ACTIVE_BRIGHTNESS; - if (state == ROOM_UI_IDLE) return text_hint ? 18 : 0; - return room_ui_state_uses_face(state) ? 40 : 18; + int brightness = overlay || canvas_active ? ROOM_CANVAS_ACTIVE_BRIGHTNESS + : state == ROOM_UI_IDLE ? (text_hint ? 18 : idle_brightness) + : room_ui_state_uses_face(state) ? 40 : 18; + return camera && brightness < ROOM_UI_CAMERA_MIN_BRIGHTNESS + ? ROOM_UI_CAMERA_MIN_BRIGHTNESS : brightness; } /* Paints `current_state`/`current_detail` with the display lock held. Returns @@ -194,25 +295,27 @@ static bool room_ui_render_locked(void) char gateway[sizeof(gateway_text)]; memcpy(gateway, gateway_text, sizeof(gateway)); bool overlay = diagnostics_open; + room_ui_facts_t facts = current_facts; taskEXIT_CRITICAL(&state_mux); bool canvas_active = room_canvas_is_active(); if (gateway_label != NULL) { /* The gateway line rides along with every non-canvas view. */ lv_label_set_text(gateway_label, gateway); - if (canvas_active || gateway[0] == '\0') { + if (home != NULL || canvas_active || overlay || gateway[0] == '\0') { lv_obj_add_flag(gateway_label, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(gateway_label, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(gateway_label); } } - if (diagnostics_hint_label != NULL) { + if (home != NULL) { if (canvas_active || overlay) { - lv_obj_add_flag(diagnostics_hint_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(home, LV_OBJ_FLAG_HIDDEN); } else { - lv_obj_clear_flag(diagnostics_hint_label, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(diagnostics_hint_label); + home_render(&facts, state, detail); + lv_obj_clear_flag(home, LV_OBJ_FLAG_HIDDEN); } + if (camera_indicator != NULL) lv_obj_move_foreground(camera_indicator); } if (canvas_active) { room_face_hide(); @@ -245,6 +348,7 @@ static bool room_ui_render_locked(void) /* The rounded glass clips the corners; top-center stays visible. */ lv_obj_align(talk_pill, LV_ALIGN_TOP_MID, 0, 14); } + if (camera_indicator != NULL) lv_obj_move_foreground(camera_indicator); return true; } if (talk_pill != NULL) { @@ -252,6 +356,12 @@ static bool room_ui_render_locked(void) talk_pill = NULL; talk_pill_label = NULL; } + if (home != NULL) { + room_face_hide(); + room_face_reset_mood(); + lv_obj_add_flag(status_label, LV_OBJ_FLAG_HIDDEN); + return true; + } if (room_ui_state_uses_face(state)) { room_face_show( state == ROOM_UI_SPEAKING ? ROOM_FACE_SPEAKING @@ -326,6 +436,14 @@ void room_ui_set(room_ui_state_t state, const char *detail) room_ui_paint_and_unlock(); } +void room_ui_store_facts(const room_ui_facts_t *facts) +{ + if (facts == NULL) return; + taskENTER_CRITICAL(&state_mux); + current_facts = *facts; + taskEXIT_CRITICAL(&state_mux); +} + void room_ui_set_gateway(const char *gateway_host) { taskENTER_CRITICAL(&state_mux); @@ -395,7 +513,7 @@ void room_ui_show_face_hint(uint32_t show_ms) text_hint_active = false; taskEXIT_CRITICAL(&state_mux); } - room_board_display_brightness_set(timer_started ? 18 : 0); + room_ui_refresh(); } } @@ -434,6 +552,9 @@ esp_err_t room_ui_camera_indicator_begin(void) lv_obj_align(camera_indicator, LV_ALIGN_TOP_RIGHT, -18, 18); lv_obj_clear_flag(camera_indicator, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(camera_indicator); + taskENTER_CRITICAL(&state_mux); + camera_indicator_active = true; + taskEXIT_CRITICAL(&state_mux); lv_refr_now(lv_display_get_default()); room_board_display_unlock(); taskENTER_CRITICAL(&state_mux); @@ -456,6 +577,9 @@ void room_ui_camera_indicator_end(void) if (camera_indicator != NULL) { lv_obj_delete(camera_indicator); camera_indicator = NULL; + taskENTER_CRITICAL(&state_mux); + camera_indicator_active = false; + taskEXIT_CRITICAL(&state_mux); lv_refr_now(lv_display_get_default()); } room_board_display_unlock(); @@ -489,9 +613,7 @@ void room_ui_set_diagnostics_open(bool open) diagnostics_open = open; taskEXIT_CRITICAL(&state_mux); if (open) { - if (diagnostics_hint_label != NULL) { - lv_obj_add_flag(diagnostics_hint_label, LV_OBJ_FLAG_HIDDEN); - } + if (home != NULL) lv_obj_add_flag(home, LV_OBJ_FLAG_HIDDEN); if (camera_indicator != NULL) lv_obj_move_foreground(camera_indicator); room_board_display_brightness_set(ROOM_CANVAS_ACTIVE_BRIGHTNESS); } else { diff --git a/components/esp-openclaw-room-node/room_ui_controller.h b/components/esp-openclaw-room-node/room_ui_controller.h index 7d6e9cb..9042c7f 100644 --- a/components/esp-openclaw-room-node/room_ui_controller.h +++ b/components/esp-openclaw-room-node/room_ui_controller.h @@ -13,6 +13,40 @@ typedef enum { ROOM_UI_SETUP, } room_ui_state_t; +typedef enum { + ROOM_UI_WIFI_STARTING = 0, + ROOM_UI_WIFI_UNCONFIGURED, + ROOM_UI_WIFI_CONNECTING, + ROOM_UI_WIFI_CONNECTED, + ROOM_UI_WIFI_OFFLINE, + ROOM_UI_WIFI_UNAVAILABLE, +} room_ui_wifi_state_t; + +typedef enum { + ROOM_UI_GATEWAY_STARTING = 0, + ROOM_UI_GATEWAY_CONNECTING, + ROOM_UI_GATEWAY_CONNECTED, + ROOM_UI_GATEWAY_OFFLINE, + ROOM_UI_GATEWAY_NO_SESSION, +} room_ui_gateway_state_t; + +typedef enum { + ROOM_UI_TALK_STARTING = 0, + ROOM_UI_TALK_UNAVAILABLE, + ROOM_UI_TALK_WAITING, + ROOM_UI_TALK_NO_SESSION, + ROOM_UI_TALK_READY, + ROOM_UI_TALK_CONNECTING, + ROOM_UI_TALK_ACTIVE, + ROOM_UI_TALK_STOPPING, +} room_ui_talk_state_t; + +typedef struct { + room_ui_wifi_state_t wifi; + room_ui_gateway_state_t gateway; + room_ui_talk_state_t talk; +} room_ui_facts_t; + typedef struct { room_ui_state_t state; bool diagnostics_open; @@ -22,6 +56,8 @@ typedef struct { void room_ui_init(void); void room_ui_set(room_ui_state_t state, const char *detail); +/** Store one coherent snapshot without taking the display lock. Refresh after releasing owner locks. */ +void room_ui_store_facts(const room_ui_facts_t *facts); /** Repaint the most recently set state, e.g. after leaving canvas mode. */ void room_ui_refresh(void); /** Show the idle face for `show_ms` (tap wake-up or agent face.set outside a call). */ diff --git a/components/esp-openclaw-room-node/tests/README.md b/components/esp-openclaw-room-node/tests/README.md index dce6eeb..f1b9d04 100644 --- a/components/esp-openclaw-room-node/tests/README.md +++ b/components/esp-openclaw-room-node/tests/README.md @@ -1,5 +1,31 @@ # Talk lifetime source proofs +## Static home UI + +The UI host runner compiles the real UI controller and board binding with LVGL 9, +ASan and UBSan. Display, timer, Canvas, diagnostics and animated-face boundaries +are synthetic; it does not access a device, network, Gateway or provider. + +```sh +python3 components/esp-openclaw-room-node/tests/run_ui_host_tests.py --lvgl-dir "$LVGL" +``` + +`LVGL` points to existing LVGL 9 sources, such as a configured room example's +`managed_components/lvgl__lvgl`. Optional `--snapshot home.ppm` writes the +synthetic Tab5 framebuffer. CI uses the existing Waveshare build's dependency. +The three cases cover Tab5 idle brightness, zero-default display sleep and the +animated-board path. They check independent connection text, retained facts +after paint lock failure, noninteractive bounded home content, tap/hold +dispatch, Canvas/diagnostics visibility, camera-indicator priority and +brightness, hint expiry, explicit off requests and a nonblank rendered mascot. +Error details remain visible alongside ready connection facts, yield to +Diagnostics, and clear on a later non-error state. +The lifecycle runner also checks real node/operator/network event handling for +the home facts, including missing-session results versus ordinary disconnects, +raw busy/failure results, accepted reconnects and later connection failures. + +## Talk lifecycle + These tests compile the real room controller and Talk adapter against synthetic Node, WebRTC, media, UI and scheduling boundaries. They do not operate hardware or connect to a Gateway/provider. Production deployment delta is **ZERO**. diff --git a/components/esp-openclaw-room-node/tests/host/esp_event.h b/components/esp-openclaw-room-node/tests/host/esp_event.h index 23dd9f3..4824324 100644 --- a/components/esp-openclaw-room-node/tests/host/esp_event.h +++ b/components/esp-openclaw-room-node/tests/host/esp_event.h @@ -1,4 +1,11 @@ #pragma once +#include #include "esp_err.h" #define ESP_EVENT_DECLARE_BASE(name) extern const char *name +typedef const char *esp_event_base_t; +#define ESP_EVENT_ANY_ID -1 +ESP_EVENT_DECLARE_BASE(IP_EVENT); +enum { IP_EVENT_STA_GOT_IP, IP_EVENT_STA_LOST_IP }; +esp_err_t esp_event_handler_register(esp_event_base_t base, int32_t id, + void (*handler)(void *, esp_event_base_t, int32_t, void *), void *ctx); esp_err_t esp_event_loop_create_default(void); diff --git a/components/esp-openclaw-room-node/tests/host/esp_wifi.h b/components/esp-openclaw-room-node/tests/host/esp_wifi.h new file mode 100644 index 0000000..6d3ccf6 --- /dev/null +++ b/components/esp-openclaw-room-node/tests/host/esp_wifi.h @@ -0,0 +1,4 @@ +#pragma once +#include "esp_event.h" +ESP_EVENT_DECLARE_BASE(WIFI_EVENT); +enum { WIFI_EVENT_STA_START, WIFI_EVENT_STA_STOP, WIFI_EVENT_STA_CONNECTED, WIFI_EVENT_STA_DISCONNECTED }; diff --git a/components/esp-openclaw-room-node/tests/host/room_host_fakes.c b/components/esp-openclaw-room-node/tests/host/room_host_fakes.c index 51dbd7e..cc86c70 100644 --- a/components/esp-openclaw-room-node/tests/host/room_host_fakes.c +++ b/components/esp-openclaw-room-node/tests/host/room_host_fakes.c @@ -340,10 +340,9 @@ esp_err_t esp_openclaw_node_register_command(esp_openclaw_node_handle_t node, esp_err_t esp_openclaw_node_request_connect(esp_openclaw_node_handle_t node, const esp_openclaw_node_connect_request_t *request) { - (void)node; host_require(request->source == ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION, "synthetic saved-session connect only; no credentials accessed"); - return ESP_OK; + return strcmp(node->config.role, "node") == 0 ? host.node_connect_result : host.operator_connect_result; } esp_err_t esp_openclaw_node_request_disconnect(esp_openclaw_node_handle_t node) { (void)node; return ESP_OK; } @@ -356,10 +355,15 @@ void host_emit_node(esp_openclaw_node_handle_t node, esp_openclaw_node_event_t e const esp_openclaw_node_disconnected_event_t disconnected = { .reason = ESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST, }; + const esp_openclaw_node_connect_failed_event_t failed = { + .reason = ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILED, + .local_err = ESP_FAIL, + }; host_require(node->config.event_cb != NULL, "registered Node event callback"); ++host.callback_depth; node->config.event_cb(node, event, - event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED ? &disconnected : NULL, + event == ESP_OPENCLAW_NODE_EVENT_DISCONNECTED ? (const void *)&disconnected + : event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED ? (const void *)&failed : NULL, node->config.event_user_ctx); --host.callback_depth; } @@ -595,6 +599,10 @@ esp_err_t room_media_get_webrtc_provider(esp_webrtc_media_provider_t *provider) } void room_ui_set(room_ui_state_t state, const char *detail) { (void)detail; host.ui = state; } +void room_ui_store_facts(const room_ui_facts_t *facts) { host.home = *facts; } +void room_ui_refresh(void) {} +const char *WIFI_EVENT = "wifi"; +const char *IP_EVENT = "ip"; void room_ui_show_face_hint(uint32_t ms) { (void)ms; } void room_ui_set_gateway(const char *gateway) { (void)gateway; } bool room_ui_talk_face_active(void) { return host.ui == ROOM_UI_SPEAKING; } @@ -644,7 +652,7 @@ const char *room_ui_state_name(room_ui_state_t state) void room_canvas_get_diagnostics(room_canvas_diagnostics_snapshot_t *snapshot) { (void)snapshot; unsupported_boundary(__func__); } void esp_openclaw_node_wifi_get_status(esp_openclaw_node_wifi_status_t *snapshot) -{ (void)snapshot; unsupported_boundary(__func__); } +{ *snapshot = host.wifi; } size_t heap_caps_get_free_size(uint32_t caps) { (void)caps; unsupported_boundary(__func__); } size_t heap_caps_get_largest_free_block(uint32_t caps) diff --git a/components/esp-openclaw-room-node/tests/host/room_host_fakes.h b/components/esp-openclaw-room-node/tests/host/room_host_fakes.h index 9e31e73..52b90f0 100644 --- a/components/esp-openclaw-room-node/tests/host/room_host_fakes.h +++ b/components/esp-openclaw-room-node/tests/host/room_host_fakes.h @@ -9,6 +9,7 @@ #include "freertos/semphr.h" #include "freertos/timers.h" #include "room_ui_controller.h" +#include "esp_openclaw_node_wifi.h" /* Single-threaded schedule points, not a model of the controller. */ typedef struct { @@ -18,6 +19,9 @@ typedef struct { unsigned critical_depth, callback_depth; bool media_owned, ambient, inside_start; room_ui_state_t ui; + room_ui_facts_t home; + esp_openclaw_node_wifi_status_t wifi; + esp_err_t node_connect_result, operator_connect_result; char close_voice[129], close_key[257]; esp_openclaw_node_handle_t close_node; void (*at_media_begin)(void); diff --git a/components/esp-openclaw-room-node/tests/run_ui_host_tests.py b/components/esp-openclaw-room-node/tests/run_ui_host_tests.py new file mode 100644 index 0000000..7fbd0d9 --- /dev/null +++ b/components/esp-openclaw-room-node/tests/run_ui_host_tests.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Render the real room UI with LVGL, without ESP-IDF, networking, or hardware.""" + +import argparse +from pathlib import Path +import subprocess +import sys +import tempfile + + +SDK_HEADERS = { + "esp_err.h": """ +typedef int esp_err_t; +#define ESP_OK 0 +#define ESP_ERR_INVALID_ARG 1 +#define ESP_ERR_INVALID_STATE 2 +#define ESP_ERR_TIMEOUT 3 +#define ESP_ERR_NO_MEM 4 +""", + "esp_codec_dev.h": "typedef void *esp_codec_dev_handle_t;", + "esp_openclaw_node.h": """ +typedef struct esp_openclaw_node *esp_openclaw_node_handle_t; +typedef struct esp_openclaw_node_error esp_openclaw_node_error_t; +""", + "cJSON.h": "typedef struct cJSON cJSON;", + "esp_log.h": """ +#define ESP_LOGE(tag, ...) ((void)(tag)) +#define ESP_LOGW(tag, ...) ((void)(tag)) +""", + "esp_timer.h": """ +#include +#include "esp_err.h" +typedef struct ui_timer *esp_timer_handle_t; +typedef struct { void (*callback)(void *); void *arg; const char *name; } esp_timer_create_args_t; +esp_err_t esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *); +esp_err_t esp_timer_stop(esp_timer_handle_t); +esp_err_t esp_timer_start_once(esp_timer_handle_t, uint64_t); +int64_t esp_timer_get_time(void); +""", + "freertos/FreeRTOS.h": """ +#include +typedef unsigned portMUX_TYPE; +#define portMUX_INITIALIZER_UNLOCKED 0 +void ui_enter_critical(portMUX_TYPE *); +void ui_exit_critical(portMUX_TYPE *); +#define taskENTER_CRITICAL(lock) ui_enter_critical(lock) +#define taskEXIT_CRITICAL(lock) ui_exit_critical(lock) +size_t strlcpy(char *, const char *, size_t); +""", + "freertos/task.h": '#include "freertos/FreeRTOS.h"', +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lvgl-dir", type=Path, required=True, help="Read-only LVGL 9 source directory") + parser.add_argument("--snapshot", type=Path, help="Optional synthetic Tab5 framebuffer in PPM format") + args = parser.parse_args() + lvgl = args.lvgl_dir.resolve() + if not (lvgl / "src/lvgl.h").exists() and not (lvgl / "lvgl.h").exists(): + parser.error("--lvgl-dir must contain LVGL sources") + tests = Path(__file__).resolve().parent + component = tests.parent + bitmap = (component / "assets/openclaw_lobster.argb8888").read_bytes() + if len(bitmap) != 180 * 180 * 4: + parser.error("compiled mascot must contain exactly 180 x 180 ARGB8888 pixels") + with tempfile.TemporaryDirectory(prefix="room-ui-host-") as directory: + root = Path(directory) + for name, contents in SDK_HEADERS.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#pragma once\n" + contents + "\n") + # The firmware linker embeds these same bytes; the host linker has no IDF helper. + pixels = root / "pixels.c" + pixels.write_text( + 'const unsigned char pixels[] __asm__("_binary_openclaw_lobster_argb8888_start") = {\n' + + ",".join(str(value) for value in bitmap) + "\n};\n" + ) + binary = root / "room-ui-test" + command = [ + "cc", "-std=gnu11", "-O1", "-g", "-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-omit-frame-pointer", + "-DLV_CONF_SKIP", "-DLV_USE_OS=LV_OS_NONE", + "-DLV_USE_STDLIB_MALLOC=LV_STDLIB_CLIB", "-DLV_USE_LOG=0", + "-DLV_FONT_MONTSERRAT_20=1", "-DLV_FONT_MONTSERRAT_28=1", + "-I", str(root), "-I", str(component), "-I", str(component / "include"), + "-I", str(lvgl), str(tests / "test_room_ui_controller.c"), + str(component / "room_ui_controller.c"), str(component / "room_board.c"), + str(pixels), *map(str, sorted((lvgl / "src").rglob("*.c"))), + "-lm", "-o", str(binary), + ] + print("Building real room UI + LVGL host renderer", flush=True) + subprocess.run(command, check=True) + for scenario in ("tab5", "default-off", "animated"): + arguments = [str(binary), scenario] + if args.snapshot and scenario == "tab5": + arguments.append(str(args.snapshot.resolve())) + subprocess.run(arguments, check=True) + + +if __name__ == "__main__": + try: + main() + except subprocess.CalledProcessError as error: + sys.exit(error.returncode if error.returncode > 0 else 1) diff --git a/components/esp-openclaw-room-node/tests/test_room_audio_port_compat.py b/components/esp-openclaw-room-node/tests/test_room_audio_port_compat.py index f0da3d7..1a70eed 100644 --- a/components/esp-openclaw-room-node/tests/test_room_audio_port_compat.py +++ b/components/esp-openclaw-room-node/tests/test_room_audio_port_compat.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Compile a legacy board initializer against the real public room-node header.""" +"""Compile legacy board initializers against the real public room-node header.""" from pathlib import Path import subprocess @@ -22,6 +22,13 @@ static int board_context; +static lv_display_t *start_display(void *ctx) { return ctx; } +static bool lock_display(void *ctx, uint32_t timeout) +{ return ctx == &board_context && timeout == 100; } +static void unlock_display(void *ctx) { assert(ctx == &board_context); } +static esp_err_t brightness(void *ctx, int percent) +{ return ctx == &board_context ? percent : -1; } + static esp_err_t open_audio(void *ctx, esp_openclaw_room_audio_handles_t *handles) { return ctx == &board_context && handles != NULL ? 0 : -1; @@ -39,6 +46,15 @@ assert(audio.playback_volume == 75); assert(audio.configure_input_gain && audio.input_gain_db == 30.0f); assert(audio.playback_gain_db == 0.0f); + esp_openclaw_room_display_port_t display = { + start_display, NULL, lock_display, unlock_display, brightness, + 1280, 720, 24, false, 50, &board_context + }; + assert(display.ctx == &board_context && display.idle_brightness == 0); + assert(display.start(display.ctx) == (lv_display_t *)&board_context); + assert(display.lock(display.ctx, 100)); + display.unlock(display.ctx); + assert(display.set_brightness(display.ctx, 0) == 0); return 0; } """ @@ -61,7 +77,7 @@ def main(): check=True, ) subprocess.run([str(binary)], check=True) - print("room audio-port positional compatibility test passed") + print("room audio/display-port positional compatibility test passed") if __name__ == "__main__": diff --git a/components/esp-openclaw-room-node/tests/test_room_talk_lifecycle.c b/components/esp-openclaw-room-node/tests/test_room_talk_lifecycle.c index b78f1bb..cdca6a0 100644 --- a/components/esp-openclaw-room-node/tests/test_room_talk_lifecycle.c +++ b/components/esp-openclaw-room-node/tests/test_room_talk_lifecycle.c @@ -27,6 +27,7 @@ static void bootstrap(void) host_require(xTaskCreate(talk_teardown_task, "talk_teardown", 4096, NULL, 6, NULL) == pdPASS, "teardown worker registered"); media_ready = true; + media_initialized = true; host_require(start_node_client() == ESP_OK, "real node command registration"); host_emit_node(node_client, ESP_OPENCLAW_NODE_EVENT_CONNECTED); host_fire_operator_timer(operator_start_timer); @@ -59,6 +60,63 @@ static void connect_call(void) host_require(talk_active && !talk_dialing && host.media_owned && !host.ambient && host.ui == ROOM_UI_SPEAKING, "connected media precondition"); } + +static void home_connection_facts(void) +{ + host_run_task("talk_teardown"); + CHECK(host.home.gateway == ROOM_UI_GATEWAY_CONNECTED && host.home.talk == ROOM_UI_TALK_READY, + "node and operator connections independently make Gateway connected and Talk ready"); + host.wifi.has_saved_network = true; + host.wifi.connected = true; + home_network_event(NULL, IP_EVENT, IP_EVENT_STA_GOT_IP, NULL); + CHECK(host.home.wifi == ROOM_UI_WIFI_CONNECTED, "IP acquisition updates Wi-Fi"); + home_network_event(NULL, IP_EVENT, IP_EVENT_STA_LOST_IP, NULL); + CHECK(host.home.wifi == ROOM_UI_WIFI_OFFLINE && host.home.gateway == ROOM_UI_GATEWAY_CONNECTED, + "IP loss is not a node-session or operator-session claim"); + host_emit_node(node_client, ESP_OPENCLAW_NODE_EVENT_DISCONNECTED); + CHECK(host.home.gateway == ROOM_UI_GATEWAY_OFFLINE && host.home.talk == ROOM_UI_TALK_READY, + "node disconnect does not mean unpaired or revoke the operator"); + host_emit_node(operator_client, ESP_OPENCLAW_NODE_EVENT_DISCONNECTED); + host_run_task("talk_teardown"); + CHECK(host.home.talk == ROOM_UI_TALK_WAITING, "operator disconnect waits, not missing session"); + host.node_connect_result = ESP_ERR_NOT_FOUND; + CHECK(request_node_connection() == ESP_ERR_NOT_FOUND && host.home.gateway == ROOM_UI_GATEWAY_NO_SESSION, + "only a missing saved-session response requests node pairing"); + host.operator_connect_result = ESP_ERR_NOT_FOUND; + host_fire_operator_timer(operator_start_timer); + host_run_task("operator_start"); + CHECK(host.home.talk == ROOM_UI_TALK_NO_SESSION, "missing operator session is distinct from node pairing"); + const esp_err_t rejected[] = {ESP_ERR_INVALID_STATE, ESP_FAIL}; + for (size_t i = 0; i < sizeof(rejected) / sizeof(*rejected); ++i) { + host.node_connect_result = rejected[i]; + CHECK(request_node_connection() == rejected[i] && host.home.gateway == ROOM_UI_GATEWAY_NO_SESSION, + "busy or failed node requests do not disprove a missing session"); + host.operator_connect_result = rejected[i]; + host_fire_operator_timer(operator_start_timer); + host_run_task("operator_start"); + CHECK(host.home.talk == ROOM_UI_TALK_NO_SESSION, + "busy normalized to success and other operator failures retain the absence fact"); + host_emit_node(operator_client, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED); + host_run_task("talk_teardown"); + } + host.node_connect_result = ESP_OK; + CHECK(request_node_connection() == ESP_OK && host.home.gateway == ROOM_UI_GATEWAY_CONNECTING, + "an accepted node request clears missing material, not connection readiness"); + host.operator_connect_result = ESP_OK; + host_fire_operator_timer(operator_start_timer); + host_run_task("operator_start"); + CHECK(host.home.talk == ROOM_UI_TALK_WAITING, "accepted operator reconnect is not Talk ready"); + host_emit_node(node_client, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED); + host_emit_node(operator_client, ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED); + host_run_task("talk_teardown"); + CHECK(host.home.gateway == ROOM_UI_GATEWAY_OFFLINE && host.home.talk == ROOM_UI_TALK_WAITING, + "failed accepted connections do not resurrect stale missing-session facts"); + host_emit_node(node_client, ESP_OPENCLAW_NODE_EVENT_CONNECTED); + host_emit_node(operator_client, ESP_OPENCLAW_NODE_EVENT_CONNECTED); + host_run_task("talk_teardown"); + CHECK(host.home.gateway == ROOM_UI_GATEWAY_CONNECTED && host.home.talk == ROOM_UI_TALK_READY, + "authoritative connections clear prior missing-session facts"); +} static void drain(void) { host_run_task("talk_teardown"); } static void stop(void) { host_command(node_client, "talk.stop"); } @@ -68,6 +126,7 @@ static void expect_open(void) CHECK(host.media_ends == 0 && host.media_owned && !host.ambient, "unrelated event retains media ownership"); CHECK(webrtc != NULL && talk_active, "unrelated event retains active call"); CHECK(host.ui == ROOM_UI_SPEAKING, "unrelated event retains speaking UI"); + CHECK(host.home.talk == ROOM_UI_TALK_ACTIVE, "active Talk has its own home fact"); } static void expect_closed(void) { @@ -560,6 +619,7 @@ static void timeout_stop(void) } static const struct { const char *name; void (*run)(void); } cases[] = { + {"home-connection-facts", home_connection_facts}, {"late-create-replacement", late_create_replacement}, {"wake-admission-loss", wake_admission_loss}, {"loss-before-worker", loss_before_worker}, diff --git a/components/esp-openclaw-room-node/tests/test_room_ui_controller.c b/components/esp-openclaw-room-node/tests/test_room_ui_controller.c new file mode 100644 index 0000000..e19f36a --- /dev/null +++ b/components/esp-openclaw-room-node/tests/test_room_ui_controller.c @@ -0,0 +1,285 @@ +#include +#include +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "room_board.h" +#include "room_canvas.h" +#include "room_diagnostics.h" +#include "room_face.h" +#include "room_ui_controller.h" + +static lv_display_t *display; +static uint8_t *frame, *draw_buffer; +static int width, height, brightness, display_depth, critical_depth; +static bool deny_display, canvas_active, face_visible; +static unsigned toggles, holds; +static lv_obj_t *modal; +static room_canvas_action_handler_t canvas_action; +struct ui_timer { esp_timer_create_args_t args; bool active; }; +static struct ui_timer timers[4]; +static unsigned timer_count; + +void ui_enter_critical(portMUX_TYPE *lock) { (void)lock; ++critical_depth; } +void ui_exit_critical(portMUX_TYPE *lock) { (void)lock; assert(critical_depth-- > 0); } +size_t strlcpy(char *dest, const char *source, size_t capacity) +{ + size_t length = strlen(source); + if (capacity) { + size_t copy = length < capacity - 1 ? length : capacity - 1; + memcpy(dest, source, copy); + dest[copy] = '\0'; + } + return length; +} +esp_err_t esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *timer) +{ + assert(timer_count < sizeof(timers) / sizeof(*timers)); + *timer = &timers[timer_count++]; + (*timer)->args = *args; + return ESP_OK; +} +esp_err_t esp_timer_stop(esp_timer_handle_t timer) { timer->active = false; return ESP_OK; } +esp_err_t esp_timer_start_once(esp_timer_handle_t timer, uint64_t us) +{ assert(us > 0); timer->active = true; return ESP_OK; } +int64_t esp_timer_get_time(void) { return 1000000; } + +static void fire_timer(const char *name) +{ + for (unsigned i = 0; i < timer_count; ++i) { + if (timers[i].active && strcmp(timers[i].args.name, name) == 0) { + timers[i].active = false; + timers[i].args.callback(timers[i].args.arg); + return; + } + } + assert(!"expected active timer"); +} + +void room_face_set_controller(const room_face_controller_t *controller) { assert(controller); } +esp_err_t room_face_create(lv_obj_t *parent) { assert(parent); return ESP_OK; } +void room_face_show(room_face_state_t state) { (void)state; face_visible = true; } +void room_face_show_hint(int64_t until) +{ (void)until; face_visible = room_board_config()->display.animated_face; } +void room_face_hide(void) { face_visible = false; } +bool room_face_is_visible(void) { return face_visible; } +void room_face_reset_mood(void) {} +void room_canvas_set_action_handler(room_canvas_action_handler_t handler) { canvas_action = handler; } +bool room_canvas_is_active(void) { return canvas_active; } +void room_canvas_view_toggle(void) { ++toggles; } +bool room_diagnostics_is_open(void) { return modal != NULL; } +esp_err_t room_diagnostics_open(void) +{ + ++holds; + modal = lv_obj_create(lv_layer_top()); + lv_obj_set_size(modal, width, height); + room_ui_set_diagnostics_open(true); + return ESP_OK; +} +esp_err_t room_diagnostics_close(void) +{ + lv_obj_delete(modal); + modal = NULL; + room_ui_set_diagnostics_open(false); + return ESP_OK; +} + +static void flush(lv_display_t *target, const lv_area_t *area, uint8_t *pixels) +{ + int row_bytes = lv_area_get_width(area) * 4; + for (int y = area->y1; y <= area->y2; ++y) { + memcpy(frame + ((size_t)y * width + area->x1) * 4, + pixels + (size_t)(y - area->y1) * row_bytes, row_bytes); + } + lv_display_flush_ready(target); +} +static lv_display_t *start_display(void *ctx) { (void)ctx; return display; } +static bool lock_display(void *ctx, uint32_t ms) +{ + (void)ctx; (void)ms; + assert(critical_depth == 0); + if (deny_display) return false; + ++display_depth; + return true; +} +static void unlock_display(void *ctx) { (void)ctx; assert(display_depth-- > 0); } +static esp_err_t set_brightness(void *ctx, int value) +{ + (void)ctx; + assert(critical_depth == 0 && value >= 0 && value <= 100); + brightness = value; + return ESP_OK; +} +static esp_err_t open_audio(void *ctx, esp_openclaw_room_audio_handles_t *handles) +{ (void)ctx; (void)handles; return ESP_OK; } + +static lv_obj_t *find_text(lv_obj_t *parent, const char *text) +{ + if (lv_obj_check_type(parent, &lv_label_class) && + strcmp(lv_label_get_text(parent), text) == 0) return parent; + for (unsigned i = 0; i < lv_obj_get_child_count(parent); ++i) { + lv_obj_t *found = find_text(lv_obj_get_child(parent, i), text); + if (found) return found; + } + return NULL; +} +static bool visible_text(const char *text) +{ + lv_obj_update_layout(lv_screen_active()); + lv_obj_t *label = find_text(lv_screen_active(), text); + return label && lv_obj_is_visible(label); +} +static unsigned check_home_tree(lv_obj_t *parent) +{ + unsigned count = 1; + assert(!lv_obj_has_flag(parent, LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_SCROLLABLE)); + lv_area_t area; + lv_obj_get_coords(parent, &area); + assert(area.x1 >= 0 && area.y1 >= 0 && area.x2 < width && area.y2 < height); + for (unsigned i = 0; i < lv_obj_get_child_count(parent); ++i) + count += check_home_tree(lv_obj_get_child(parent, i)); + return count; +} +static void snapshot(const char *path) +{ + lv_refr_now(display); + unsigned red = 0, light = 0; + for (int i = 0; i < width * height; ++i) { + uint8_t *p = frame + i * 4; + red += p[2] > 140 && p[2] > p[1] * 2; + light += p[0] > 150 && p[1] > 150 && p[2] > 150; + } + assert(red > 2000 && light > 500); + if (!path) return; + FILE *out = fopen(path, "wb"); + assert(out); + fprintf(out, "P6\n%d %d\n255\n", width, height); + for (int i = 0; i < width * height; ++i) { + uint8_t rgb[] = {frame[i * 4 + 2], frame[i * 4 + 1], frame[i * 4]}; + assert(fwrite(rgb, sizeof(rgb), 1, out) == 1); + } + assert(fclose(out) == 0); +} + +int main(int argc, char **argv) +{ + assert(argc >= 2); + bool animated = strcmp(argv[1], "animated") == 0; + int idle = strcmp(argv[1], "tab5") == 0 ? 18 : 0; + width = animated ? 410 : 1280; + height = animated ? 502 : 720; + lv_init(); + display = lv_display_create(width, height); + frame = calloc((size_t)width * height, 4); + draw_buffer = calloc((size_t)width * height, 4); + assert(frame && draw_buffer); + lv_display_set_color_format(display, LV_COLOR_FORMAT_XRGB8888); + lv_display_set_buffers(display, draw_buffer, NULL, (size_t)width * height * 4, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(display, flush); + esp_openclaw_room_node_config_t board = { + .display_name = "OpenClaw M5Stack Tab5 Room Node", + .model_identifier = "m5stack-tab5", + .display = {.start = start_display, .lock = lock_display, .unlock = unlock_display, + .set_brightness = set_brightness, .native_width = width, .native_height = height, + .safe_inset = 24, .animated_face = animated, .idle_brightness = 101}, + .audio = {.open = open_audio, .afe_layout = "MR", .record_channels = 4}, + }; + assert(room_board_bind(&board) == ESP_ERR_INVALID_ARG); + board.display.idle_brightness = idle; + assert(room_board_bind(&board) == ESP_OK); + room_ui_init(); + assert(brightness == idle); + if (animated) { + assert(!find_text(lv_screen_active(), "OpenClaw Room Node")); + room_ui_set(ROOM_UI_LISTENING, NULL); + assert(face_visible && brightness == 40); + room_ui_set(ROOM_UI_IDLE, NULL); + assert(brightness == 0); + } else { + assert(visible_text("OpenClaw Room Node")); + lv_obj_t *home = lv_obj_get_parent(find_text(lv_screen_active(), "OpenClaw Room Node")); + lv_obj_update_layout(lv_screen_active()); + unsigned objects = check_home_tree(home); + room_ui_facts_t facts = { + .wifi = ROOM_UI_WIFI_OFFLINE, .gateway = ROOM_UI_GATEWAY_NO_SESSION, + .talk = ROOM_UI_TALK_WAITING, + }; + room_ui_store_facts(&facts); + room_ui_set(ROOM_UI_CONNECTING, "Wi-Fi"); + assert(visible_text("Gateway Pairing required") && visible_text("Talk Waiting for operator")); + assert(visible_text("OpenClaw Room Node")); + deny_display = true; + facts.wifi = ROOM_UI_WIFI_CONNECTED; + facts.gateway = ROOM_UI_GATEWAY_CONNECTED; + facts.talk = ROOM_UI_TALK_READY; + room_ui_store_facts(&facts); + room_ui_refresh(); + assert(visible_text("Wi-Fi Offline")); + facts.wifi = ROOM_UI_WIFI_OFFLINE; + room_ui_store_facts(&facts); + room_ui_refresh(); + deny_display = false; + fire_timer("ui_repaint"); + assert(visible_text("Wi-Fi Offline") && visible_text("Gateway Connected") && visible_text("Talk Ready")); + room_ui_set(ROOM_UI_IDLE, NULL); + for (int i = 0; i < 100; ++i) room_ui_refresh(); + assert(check_home_tree(home) == objects && lv_anim_count_running() == 0); + snapshot(argc == 3 ? argv[2] : NULL); + + room_ui_set(ROOM_UI_ERROR, "Talk setup failed"); + room_ui_store_facts(&facts); + room_ui_refresh(); + assert(visible_text("Talk setup failed") && visible_text("Talk Ready")); + assert(check_home_tree(home) == objects); + lv_area_t error_area, talk_area; + lv_obj_get_coords(find_text(home, "Talk setup failed"), &error_area); + lv_obj_get_coords(find_text(home, "Talk Ready"), &talk_area); + assert(error_area.y1 > talk_area.y2); + if (argc == 3) { + char error_path[1024]; + assert(snprintf(error_path, sizeof(error_path), "%s.error.ppm", argv[2]) < (int)sizeof(error_path)); + snapshot(error_path); + } + lv_obj_send_event(lv_screen_active(), LV_EVENT_CLICKED, NULL); + assert(toggles == 1); + lv_obj_send_event(lv_screen_active(), LV_EVENT_LONG_PRESSED, NULL); + assert(holds == 1 && !visible_text("OpenClaw Room Node") && brightness == ROOM_CANVAS_ACTIVE_BRIGHTNESS); + assert(!visible_text("Talk setup failed")); + room_diagnostics_close(); + assert(visible_text("Talk setup failed")); + room_ui_set(ROOM_UI_IDLE, NULL); + assert(!visible_text("Talk setup failed")); + assert(visible_text("OpenClaw Room Node") && brightness == idle); + + assert(room_ui_camera_indicator_begin() == ESP_OK); + room_ui_refresh(); + assert(brightness >= 40); + canvas_active = true; + room_ui_set(ROOM_UI_SPEAKING, NULL); + assert(!visible_text("OpenClaw Room Node") && brightness == ROOM_CANVAS_ACTIVE_BRIGHTNESS); + lv_obj_t *camera = find_text(lv_layer_top(), LV_SYMBOL_EYE_OPEN " Camera active"); + assert(camera && lv_obj_get_child(lv_layer_top(), -1) == camera); + canvas_action(ROOM_CANVAS_ACTION_RENDER_CHANGED, 0); + room_diagnostics_open(); + assert(lv_obj_get_child(lv_layer_top(), -1) == camera); + room_diagnostics_close(); + canvas_active = false; + room_ui_set(ROOM_UI_IDLE, NULL); + assert(visible_text("OpenClaw Room Node") && brightness >= 40); + room_ui_camera_indicator_end(); + assert(brightness == idle); + room_ui_show_face_hint(1000); + assert(brightness == 18); + fire_timer("text_hint"); + assert(brightness == idle); + } + assert(room_board_display_brightness_set(0) == ESP_OK && brightness == 0); + assert(display_depth == 0 && critical_depth == 0); + lv_deinit(); + free(frame); + free(draw_buffer); + printf("PASS %s: state, power, layering and rendered bounds\n", argv[1]); + return 0; +} diff --git a/examples/m5stack-tab5-room-node/README.md b/examples/m5stack-tab5-room-node/README.md index d9d4bba..0360252 100644 --- a/examples/m5stack-tab5-room-node/README.md +++ b/examples/m5stack-tab5-room-node/README.md @@ -33,6 +33,14 @@ target. Missing transport is shown as `Wi-Fi coprocessor unavailable`. ## Display and audio +The default home shows the static OpenClaw image, board identity, independent +Wi-Fi/Gateway/Talk status, and error details. Canvas and local Diagnostics take +precedence. Tab5 sets `display.idle_brightness = 18`, keeping the home visible +at a dim idle backlight at the cost of higher idle power; other boards that +omit this field retain zero/off while idle. Explicit off requests are not +clamped. The static home does not imply full touch, camera, network or Talk +hardware qualification. + The maintained MIPI-DSI/LVGL stack rotates to 1280x720 landscape and probes ILI9881C+GT911, ST7123 (touch firmware 3), and ST7121 (firmware 1). ST7123 is physically verified on the connected unit. ST7121 is compile-tested, not diff --git a/examples/m5stack-tab5-room-node/components/tab5_room_board/tab5_room_board.c b/examples/m5stack-tab5-room-node/components/tab5_room_board/tab5_room_board.c index c64802d..a046ed6 100644 --- a/examples/m5stack-tab5-room-node/components/tab5_room_board/tab5_room_board.c +++ b/examples/m5stack-tab5-room-node/components/tab5_room_board/tab5_room_board.c @@ -1173,9 +1173,10 @@ esp_err_t tab5_room_board_config(esp_openclaw_room_node_config_t *config) .native_height = 720, .safe_inset = 24, /* The full-screen procedural face exceeds this rotated pipeline's - * watchdog budget; text states keep the product responsive. */ + * watchdog budget; the static home keeps the product responsive. */ .animated_face = false, .animation_frame_ms = 50, + .idle_brightness = 18, }, .audio = { .open = tab5_audio_open, From e9ccae6d7855552c2e44c1e5f1292faa6a9028ab Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 10 Sep 2026 19:45:23 +0800 Subject: [PATCH 3/3] fix(ui): retain setup guidance on the node home --- components/esp-openclaw-room-node/README.md | 2 +- .../room_ui_controller.c | 15 +++++++----- .../tests/test_room_ui_controller.c | 23 ++++++++++++++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/components/esp-openclaw-room-node/README.md b/components/esp-openclaw-room-node/README.md index a96174f..2b4e5cc 100644 --- a/components/esp-openclaw-room-node/README.md +++ b/components/esp-openclaw-room-node/README.md @@ -11,7 +11,7 @@ controllers, remote-Wi-Fi transport, and scheduler profiles remain outside this component. Non-animated displays use a static OpenClaw home with board identity, separate -Wi-Fi/Gateway/Talk facts, and visible error details. Canvas and Diagnostics +Wi-Fi/Gateway/Talk facts, and bounded setup guidance or error details. Canvas and Diagnostics cover the home without replacing those facts. The optional trailing `display.idle_brightness` field accepts 0-100; omitted or zero preserves idle display sleep, and explicit off requests remain unchanged. A nonzero value diff --git a/components/esp-openclaw-room-node/room_ui_controller.c b/components/esp-openclaw-room-node/room_ui_controller.c index 823ce6e..1f948f9 100644 --- a/components/esp-openclaw-room-node/room_ui_controller.c +++ b/components/esp-openclaw-room-node/room_ui_controller.c @@ -33,7 +33,7 @@ static lv_obj_t *home; static lv_obj_t *home_wifi; static lv_obj_t *home_gateway; static lv_obj_t *home_talk; -static lv_obj_t *home_error; +static lv_obj_t *home_detail; static lv_obj_t *talk_pill; static lv_obj_t *talk_pill_label; static lv_obj_t *camera_indicator; @@ -115,8 +115,8 @@ static void home_create(lv_display_t *display, const esp_openclaw_room_node_conf home_wifi = home_label("Wi-Fi Starting", font, 0x9aabaa); home_gateway = home_label("Gateway Starting", font, 0x9aabaa); home_talk = home_label("Talk Starting", font, 0x9aabaa); - home_error = home_label("", &lv_font_montserrat_14, 0xff8383); - lv_obj_set_height(home_error, 48); + home_detail = home_label("", &lv_font_montserrat_14, 0x9aabaa); + lv_obj_set_height(home_detail, 48); } static void home_render(const room_ui_facts_t *facts, room_ui_state_t state, const char *detail) @@ -147,9 +147,12 @@ static void home_render(const room_ui_facts_t *facts, room_ui_state_t state, con lv_obj_set_style_text_color(home_talk, lv_color_hex(facts->talk == ROOM_UI_TALK_ACTIVE ? 0xf4c16b : facts->talk == ROOM_UI_TALK_READY ? 0x54d6af : 0x9aabaa), 0); - const char *error = state == ROOM_UI_ERROR ? (detail[0] != '\0' ? detail : "Error") : ""; - if (strcmp(lv_label_get_text(home_error), error) != 0) { - lv_label_set_text(home_error, error); + const char *text = state == ROOM_UI_ERROR ? (detail[0] != '\0' ? detail : "Error") + : state == ROOM_UI_SETUP ? detail : ""; + lv_obj_set_style_text_color(home_detail, + lv_color_hex(state == ROOM_UI_ERROR ? 0xff8383 : 0x9aabaa), 0); + if (strcmp(lv_label_get_text(home_detail), text) != 0) { + lv_label_set_text(home_detail, text); } } diff --git a/components/esp-openclaw-room-node/tests/test_room_ui_controller.c b/components/esp-openclaw-room-node/tests/test_room_ui_controller.c index e19f36a..f994a0d 100644 --- a/components/esp-openclaw-room-node/tests/test_room_ui_controller.c +++ b/components/esp-openclaw-room-node/tests/test_room_ui_controller.c @@ -228,6 +228,26 @@ int main(int argc, char **argv) assert(check_home_tree(home) == objects && lv_anim_count_running() == 0); snapshot(argc == 3 ? argv[2] : NULL); + const char *setup_details[] = { + "USB console:\ngateway setup-code", + "USB console:\nwifi set + setup-code", + }; + for (size_t i = 0; i < sizeof(setup_details) / sizeof(*setup_details); ++i) { + room_ui_set(ROOM_UI_SETUP, setup_details[i]); + assert(visible_text(setup_details[i]) && visible_text("OpenClaw Room Node")); + assert(visible_text("Gateway Connected") && visible_text("Talk Ready")); + assert(check_home_tree(home) == objects); + lv_area_t detail_area, talk_area; + lv_obj_get_coords(find_text(home, setup_details[i]), &detail_area); + lv_obj_get_coords(find_text(home, "Talk Ready"), &talk_area); + assert(detail_area.y1 > talk_area.y2 && detail_area.y2 < height); + room_diagnostics_open(); + assert(!visible_text(setup_details[i])); + room_diagnostics_close(); + assert(visible_text(setup_details[i])); + room_ui_set(ROOM_UI_IDLE, NULL); + assert(!visible_text(setup_details[i]) && brightness == idle); + } room_ui_set(ROOM_UI_ERROR, "Talk setup failed"); room_ui_store_facts(&facts); room_ui_refresh(); @@ -244,8 +264,9 @@ int main(int argc, char **argv) } lv_obj_send_event(lv_screen_active(), LV_EVENT_CLICKED, NULL); assert(toggles == 1); + unsigned holds_before = holds; lv_obj_send_event(lv_screen_active(), LV_EVENT_LONG_PRESSED, NULL); - assert(holds == 1 && !visible_text("OpenClaw Room Node") && brightness == ROOM_CANVAS_ACTIVE_BRIGHTNESS); + assert(holds == holds_before + 1 && !visible_text("OpenClaw Room Node") && brightness == ROOM_CANVAS_ACTIVE_BRIGHTNESS); assert(!visible_text("Talk setup failed")); room_diagnostics_close(); assert(visible_text("Talk setup failed"));