Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/BRIDGE_AI_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
14 changes: 13 additions & 1 deletion tools/platformio_apply_wifi_bridge_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 4 additions & 3 deletions tools/test_firmware_reproducible_build_contract.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) `
Expand Down Expand Up @@ -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."
45 changes: 45 additions & 0 deletions tools/test_platformio_wifi_env_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions tools/test_release_boot_motion_contract.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down