diff --git a/docs/BRIDGE_AI_HANDOFF.md b/docs/BRIDGE_AI_HANDOFF.md index 469c6ce..96005e7 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 2dd7034..94734d8 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 bc5865a..4626f02 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 d72d8d6..c961b41 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 609cd42..6a0fe89 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 e4c55b6..eee6f20 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"