From 0884a8a49097b8103ac5bc146df47786f71b7813 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Tue, 11 Aug 2026 19:57:17 -0400 Subject: [PATCH] Give host vision a profile that does not switch motion on F3 in docs/BRIDGE_AI_HANDOFF.md is that vision delivers nothing: camera_face_batches, camera_faces_observed and camera_events are all zero, which starves face-follow, active-speaker selection, the FaceLost path, and everything in Part 3 that depends on noticing people. Two things blocked it, and neither was the code F3 points at. The installed release candidate reports compiled_enable_camera: 0 and compiled_enable_camera_host_vision: 0, so no host worker could ever have received a frame from it. The only camera-enabled profile was stackchan_camera_probe, which also sets STACKCHAN_MOTION_ENABLED_AT_BOOT=1 and STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1. Getting eyes therefore meant accepting autonomous actuator motion at boot as a side effect, which is not a trade presence detection should have to make. stackchan_release_forensics_vision takes the camera and the authenticated host-vision endpoints and leaves the inherited motion-off-at-boot posture untouched: no build_unflags, no motion markers. The boot-motion contract now asserts that directly, including the absence of build_unflags, so the profile cannot quietly acquire motion later. STACKCHAN_CAMERA_CAPTURE_PROBE_ONLY is deliberately not set. serveCameraGrayFrame() captures on demand per authenticated request, so the periodic capture probe is a separate diagnostic rather than a prerequisite for host vision. The pairing guard needed widening to match. platformio_apply_wifi_bridge_env.py recognised paired camera environments by the literal prefix "stackchan_camera_probe", so a camera image built from the new profile would not have required STACKCHAN_PAIRING_SHORT_CODE and would have served its camera endpoints unauthenticated. Rather than add one more name and leave the same trap for the next profile, the contract now derives every camera environment from platformio.ini, following extends inheritance, and requires each to refuse to build without a pairing code. stackchan_release_full is excluded from that requirement on purpose: it is the public secret-free image, it embeds no pairing code by design, and owners provision pairing after flash. Verified: release boot-motion contract passes, and the PlatformIO Wi-Fi environment contract passes 7/7 including the new derived camera-environment check. Physical qualification of the profile is not claimed here. It still needs a private per-device build, a flash, and advancing authenticated frame and target counters on the robot. Co-Authored-By: Claude Opus 5 --- docs/BRIDGE_AI_HANDOFF.md | 23 ++++++++++ platformio.ini | 26 +++++++++++ tools/platformio_apply_wifi_bridge_env.py | 14 +++++- ...t_firmware_reproducible_build_contract.ps1 | 7 +-- tools/test_platformio_wifi_env_contract.py | 45 +++++++++++++++++++ tools/test_release_boot_motion_contract.ps1 | 25 +++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index 469c6ce2..96005e74 100644 --- a/docs/BRIDGE_AI_HANDOFF.md +++ b/docs/BRIDGE_AI_HANDOFF.md @@ -250,6 +250,29 @@ vision worker and refuses a vision-enabled ready result until both authenticated target updates advance with no new frame/auth failures. Physical qualification additionally requires advancing face batches, observed faces, and camera events. +**Blocker found 2026-08-11, and what changed.** The installed release candidate reports +`compiled_enable_camera: 0` and `compiled_enable_camera_host_vision: 0`, so F3 could not be worked at +all on that image regardless of host state. The only camera-enabled profile was +`stackchan_camera_probe`, which also sets `STACKCHAN_MOTION_ENABLED_AT_BOOT=1` and +`STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1` — so the only way to get eyes was to accept autonomous +actuator motion at boot as a side effect. Presence detection must not carry that price. + +`stackchan_release_forensics_vision` now provides the camera and the authenticated host-vision +endpoints while inheriting the motion-off-at-boot posture untouched: no `build_unflags`, no motion +markers. `tools/test_release_boot_motion_contract.ps1` asserts that directly, so the profile cannot +silently acquire motion later. `STACKCHAN_CAMERA_CAPTURE_PROBE_ONLY` is deliberately not set — +`serveCameraGrayFrame()` captures on demand per authenticated request, so the periodic capture probe +is a separate diagnostic rather than a prerequisite. + +The host side is ready as of 2026-08-11: `C:\stackchan_vision_venv` exists with the pinned +`numpy==2.2.6` and `opencv-python-headless==4.13.0.92` from `bridge/requirements-vision.txt`, and +`test_vision_service` passes 8/8 in it. Before that the venv did not exist, so no vision worker could +have run on this host whatever the firmware did. + +What remains is physical: build `stackchan_release_forensics_vision` with the private per-device +configuration, flash it, start the worker with `tools/start_local_vision.ps1`, and require the +authenticated frame and target counters to advance. Nothing in Part 3 can be qualified until they do. + ## F4. Speech is subtly choppy **Observed:** the operator heard slight choppiness while the bridge used 4096-byte, 16 kHz PCM diff --git a/platformio.ini b/platformio.ini index 2dd70346..94734d8f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -471,6 +471,32 @@ build_flags = ${env:stackchan_voice_v2.build_flags} -D STACKCHAN_ENABLE_POWER_FORENSICS=1 +; Host vision without actuator authority. +; +; F3 in docs/BRIDGE_AI_HANDOFF.md needs the camera and the authenticated +; host-vision endpoints, but the only existing camera profile is +; stackchan_camera_probe, which also turns motion and autonomous motion on at +; boot. Presence detection must not require accepting actuator motion as a side +; effect, so this profile takes the camera and leaves the inherited +; motion-off-at-boot posture alone: no build_unflags, no motion markers. +; +; serveCameraGrayFrame() captures on demand per authenticated request, so +; STACKCHAN_CAMERA_CAPTURE_PROBE_ONLY is deliberately not set; the periodic +; capture probe is a separate diagnostic, not a prerequisite for host vision. +; +; Camera DMA/control allocations leave a measured steady internal heap of about +; 39 KB, so this carries the same 32 KB live safety floor as the probe profile +; rather than the 64 KB non-camera default. +[env:stackchan_release_forensics_vision] +extends = env:stackchan_release_forensics +build_flags = + ${env:stackchan_release_forensics.build_flags} + -D STACKCHAN_ENABLE_CAMERA=1 + -D STACKCHAN_ENABLE_CAMERA_HOST_VISION=1 + -D STACKCHAN_CAMERA_HMIRROR=1 + -D STACKCHAN_CAMERA_VFLIP=0 + -D STACKCHAN_OTA_MIN_FREE_HEAP_BYTES=32768 + [env:stackchan_camera_probe] extends = env:stackchan_release_forensics build_unflags = diff --git a/tools/platformio_apply_wifi_bridge_env.py b/tools/platformio_apply_wifi_bridge_env.py index bc5865a6..4626f02e 100644 --- a/tools/platformio_apply_wifi_bridge_env.py +++ b/tools/platformio_apply_wifi_bridge_env.py @@ -61,8 +61,20 @@ def escaped_define_string(name, value): if value: cc_flags.append(escaped_define_string(name, value)) +# Every environment that compiles the camera in must refuse to build without a +# pairing code, or the camera endpoints ship unauthenticated. This is matched by +# name because the PlatformIO hook cannot see the resolved build flags, so +# test_platformio_wifi_env_contract.py cross-checks these prefixes against every +# environment in platformio.ini that enables STACKCHAN_ENABLE_CAMERA, directly or +# by inheritance. Adding a camera environment without covering it here fails that +# contract. +PAIRED_CAMERA_ENVIRONMENT_PREFIXES = ( + "stackchan_camera_probe", + "stackchan_release_forensics_vision", +) + pairing_code = optional("STACKCHAN_PAIRING_SHORT_CODE") -if pio_environment.startswith("stackchan_camera_probe") and not pairing_code: +if pio_environment.startswith(PAIRED_CAMERA_ENVIRONMENT_PREFIXES) and not pairing_code: raise RuntimeError( f"{pio_environment} is a private paired-camera environment and requires " "STACKCHAN_PAIRING_SHORT_CODE" diff --git a/tools/test_firmware_reproducible_build_contract.ps1 b/tools/test_firmware_reproducible_build_contract.ps1 index d72d8d6b..c961b417 100644 --- a/tools/test_firmware_reproducible_build_contract.ps1 +++ b/tools/test_firmware_reproducible_build_contract.ps1 @@ -96,6 +96,7 @@ $expectedFirmwareEnvironments = @( "stackchan_wake_mww_uplink_servos_m5_voiceout", "stackchan_voice_v2", "stackchan_release_forensics", + "stackchan_release_forensics_vision", "stackchan_camera_probe", "stackchan_camera_probe_pmic_telemetry_only", "stackchan_camera_probe_pmic_policy_only", @@ -289,8 +290,8 @@ try { } if ($environment -eq "native_logic") { $nativeHookCount = $hookCount } } - Require-ReproAssertion ($firmwareEnvironments.Count -eq 22) ` - "effective-environment-count: expected 22 Arduino firmware environments, found $($firmwareEnvironments.Count)" + Require-ReproAssertion ($firmwareEnvironments.Count -eq 23) ` + "effective-environment-count: expected 23 Arduino firmware environments, found $($firmwareEnvironments.Count)" Require-ReproAssertion ((Compare-Object ` ($expectedFirmwareEnvironments | Sort-Object) ` ($firmwareEnvironments | Sort-Object)).Count -eq 0) ` @@ -954,4 +955,4 @@ if ($issues.Count -gt 0) { throw ("Firmware reproducible-build contract failed:`n- " + ($issues -join "`n- ")) } -Write-Host "Firmware reproducible-build contract verified for all 22 firmware environments." +Write-Host "Firmware reproducible-build contract verified for all 23 firmware environments." diff --git a/tools/test_platformio_wifi_env_contract.py b/tools/test_platformio_wifi_env_contract.py index 609cd42b..6a0fe898 100644 --- a/tools/test_platformio_wifi_env_contract.py +++ b/tools/test_platformio_wifi_env_contract.py @@ -76,11 +76,56 @@ def test_private_camera_profiles_require_pairing_code(self): "stackchan_camera_probe_pmic_telemetry_only", "stackchan_camera_probe_pmic_policy_only", "stackchan_camera_probe_pmic_all_off", + "stackchan_release_forensics_vision", ): with self.subTest(profile=profile): with self.assertRaisesRegex(RuntimeError, "requires STACKCHAN_PAIRING_SHORT_CODE"): run_hook(profile) + def test_every_camera_environment_refuses_to_build_without_pairing(self): + # The hook matches paired-camera environments by name because it cannot + # see resolved build flags. Derive the real set from platformio.ini so a + # new camera environment cannot be added without the pairing guard: a + # camera image built without a pairing code serves its camera endpoints + # unauthenticated. + ini = (Path(__file__).resolve().parents[1] / "platformio.ini").read_text(encoding="utf-8") + blocks = {} + current = None + for line in ini.splitlines(): + stripped = line.strip() + if stripped.startswith("[env:") and stripped.endswith("]"): + current = stripped[5:-1] + blocks[current] = [] + elif current is not None: + blocks[current].append(stripped) + + camera_environments = { + name + for name, body in blocks.items() + if any(entry.startswith("-D STACKCHAN_ENABLE_CAMERA=1") for entry in body) + } + # Inheritance: an environment extending a camera environment is one too. + for _ in range(len(blocks)): + for name, body in blocks.items(): + for entry in body: + if entry.startswith("extends = env:"): + if entry.split("extends = env:", 1)[1].strip() in camera_environments: + camera_environments.add(name) + + # stackchan_release_full also compiles the camera in, but it is the + # public secret-free image: it deliberately embeds no pairing code and + # owners provision pairing after flash. Requiring one at build time would + # make the public release unbuildable. Every *private* per-device camera + # environment must still refuse. + camera_environments.discard("stackchan_release_full") + + self.assertIn("stackchan_camera_probe", camera_environments) + self.assertIn("stackchan_release_forensics_vision", camera_environments) + for profile in sorted(camera_environments): + with self.subTest(profile=profile): + with self.assertRaisesRegex(RuntimeError, "requires STACKCHAN_PAIRING_SHORT_CODE"): + run_hook(profile) + def test_embedded_host_without_port_uses_canonical_bridge_port(self): fake = run_hook( "stackchan_wifi_uplink", diff --git a/tools/test_release_boot_motion_contract.ps1 b/tools/test_release_boot_motion_contract.ps1 index e4c55b61..eee6f206 100644 --- a/tools/test_release_boot_motion_contract.ps1 +++ b/tools/test_release_boot_motion_contract.ps1 @@ -33,6 +33,31 @@ $base = Get-EnvironmentBlock "stackchan_wake_mww_uplink_servos" if ($base -notmatch [regex]::Escape("-D STACKCHAN_MOTION_ENABLED_AT_BOOT=0")) { throw "The guarded test/rollback servo profile must remain motion-off at boot." } + +# The host-vision profile exists so presence detection never requires accepting +# actuator motion as a side effect. It must take the camera without acquiring the +# probe profile's boot-motion markers, directly or by inheriting an unflag. +$visionBlock = Get-EnvironmentBlock "stackchan_release_forensics_vision" +foreach ($marker in @( + "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", + "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1", + "build_unflags" +)) { + if ($visionBlock -match [regex]::Escape($marker)) { + throw "stackchan_release_forensics_vision must stay motion-off at boot; found: $marker" + } +} +foreach ($marker in @( + "-D STACKCHAN_ENABLE_CAMERA=1", + "-D STACKCHAN_ENABLE_CAMERA_HOST_VISION=1" +)) { + if ($visionBlock -notmatch [regex]::Escape($marker)) { + throw "stackchan_release_forensics_vision missing host-vision marker: $marker" + } +} +if ($visionBlock -notmatch [regex]::Escape("extends = env:stackchan_release_forensics")) { + throw "stackchan_release_forensics_vision must inherit the motion-off forensics profile." +} foreach ($unsafeMarker in @( "-D STACKCHAN_MOTION_ENABLED_AT_BOOT=1", "-D STACKCHAN_AUTONOMOUS_MOTION_AT_BOOT=1"