Parviz · Software docs

desk-pi software

Parviz robot software. Awareness/LLM design intent: ../docs/AWARENESS.md. Software spike for the tracked desk robot: face renderer, neck stepper driver, camera check. Lives here in the repo and mirrored to ~/parviz-sw on the Pi (ssh moshe@moshe-pi5-2gb.local, key auth).

Deploy

rsync -av --exclude __pycache__ software/ moshe@moshe-pi5-2gb.local:parviz-sw/

Pieces

face/face.py, fullscreen face (800x480, official 7" touchscreen)

Rigid-line robot face (2026-07-11 revision, per user): two rectangular outlined eyes with square pupils, NO mouth, NO eyebrows, single color = the body's safety orange (src/geo.py COLORS["accent"] = 232,116,34) on black. Expressions come from lid height, top-corner tilt and pupil position; every stroke is straight. Layout scales off the panel size (eyes 224x192 centered at 240/560). Idle blink stays. FaceRenderer.set_expression(name) API; expressions: neutral, happy, sad, surprised, sleepy, look_left, look_right, look_up, look_down.

TOUCH: while a finger is on the panel the pupils track it and the eyes widen slightly; lifting the finger blinks and returns to the current expression. Handles both SDL FINGER* events (the DSI ft5x06 panel) and mouse-drag (windowed dev).

Headless visual check (no compositor to screenshot through): kill -USR1 <pid> makes the next frame land in /tmp/face_frame.png.

face/parviz-face.service, boot service

The Pi boots to console (sudo systemctl set-default multi-user.target, desktop disabled; revert with graphical.target) and systemd starts the face fullscreen via KMS/DRM. SDL scans the DRM cards for a connected connector, which is always the DSI panel (HDMI unplugged), so no device index is pinned. DRM card numbers shuffle between boots; never hardcode /dev/dri/cardN.

sudo cp face/parviz-face.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now parviz-face

Needs pygame (check python3 -c "import pygame"; else pip3 install --user pygame, no sudo).

# Over SSH, console owns the display (no desktop session):
SDL_VIDEODRIVER=kmsdrm python3 face/face.py --demo --seconds 10
# (face.py sets SDL_VIDEODRIVER=kmsdrm itself when no DISPLAY/WAYLAND_DISPLAY)

# If a Wayland desktop session owns the display, kmsdrm will fail
# ("Could not initialize KMSDRM", the compositor holds DRM master).
# Run inside the session instead:
WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1000 python3 face/face.py --seconds 10
# X11 desktop: DISPLAY=:0 python3 face/face.py --seconds 10

# Dev on a laptop:
python3 face/face.py --windowed --demo --seconds 10

--seconds N auto-exits (SSH smoke tests, nothing left running). Esc/q quits.

motion/steppers.py, 28BYJ-48 half-step driver (lgpio)

PanStepper / TiltStepper on a shared LimitedStepper:

# Unit tests (any machine, no GPIO):
cd motion && python3 -m unittest test_steppers -v

# On the robot (once wired; BCM pin numbers for the ULN2003 IN1..IN4):
python3 -c "from steppers import PanStepper; PanStepper(pins=(17,18,27,22)).move_to(30)"

lgpio comes from the OS package python3-lgpio (should be present on Raspberry Pi OS trixie; if not: pip3 install --user lgpio).

camera/preview.py, one-frame capture check

Tries Picamera2, falls back to rpicam-still. Writes /tmp/desk_pi_cam.jpg and prints backend + resolution (parses JPEG SOF, no PIL needed).

python3 camera/preview.py
# expect: OK: /tmp/desk_pi_cam.jpg (... KB) via picamera2, resolution 4608x2592

Hardware verification status (2026-07-11)

All three pieces verified on the Pi (moshe-pi5-2gb.local, Pi 5 2GB, Debian 13 trixie, kernel 6.18, Python 3.13.5; pygame 2.6.1 / lgpio / picamera2 / numpy all present from the OS):

Remote screenshots: desktop session up -> grim -o DSI-2 out.png; console/service mode -> kill -USR1 $(pgrep -fn face.py) and fetch /tmp/face_frame.png (the service renders into a DRM dumb buffer, there is nothing for grim to grab).

Next hardware step

Wire one 28BYJ-48 + ULN2003 to the Pi (pick 4 BCM pins, e.g. 17/18/27/22), then run the first live coil test: python3 -c "from steppers import PanStepper; PanStepper(pins=(17,18,27,22)).move_to(30)". After that: pin map into a pins.py (or config), and a supervisor that ties face + camera + motion together.

LLM on the Pi (measured 2026-07-12)

The Pi 5 2GB runs small LLMs fine, CPU-only. Runtime: llama.cpp, built natively on the Pi (~/llama.cpp/build/bin, Cortex-A76 dotprod kernels; cmake -B build -DCMAKE_BUILD_TYPE=Release -DLLAMA_CURL=ON). Use Q4_0 quants: llama.cpp runtime-repacks Q4_0 for the ARM dotprod path, so it beats Q4_K_M on this CPU. Ollama was rejected: it wraps llama.cpp anyway and its resident Go server wastes RAM the 2GB board does not have. Models live in ~/models/.

Measured with llama-bench -t 4 (pp = prompt tokens/s, tg = generation tokens/s), face service running throughout:

model (Q4_0) weights pp256 tg64 verdict
Qwen3-0.6B 403 MiB 204 21.4 daily driver
LFM2-1.2B 661 MiB 94 11.0 quality alt
Llama-3.2-1B 730 MiB 84 9.0 slower than LFM2, skip
Qwen3-1.7B 1002 MiB 55 6.5 fits, sluggish; ceiling

End-to-end serving check (Qwen3-0.6B, llama-server -t 4 -c 2048): short persona reply in 0.94 s wall clock cold; server RSS 937 MB; face + server coexist with 1.17 GB still available. Qwen3 is a thinking model, append /no_think to the system prompt for latency.

Serve command:

~/llama.cpp/build/bin/llama-server -m ~/models/Qwen3-0.6B-Q4_0.gguf \
    -t 4 -c 2048 --host 127.0.0.1 --port 8081
# OpenAI-compatible: POST localhost:8081/v1/chat/completions

Honest quality note: 0.6B-1.7B models handle persona lines, command parsing and simple Q&A; they are not good conversationalists. For real conversation quality the right architecture is a remote brain (API or a bigger box) with the local model as the offline/latency fallback. Budget rule: keep model + KV under ~1.2 GB so the face, camera and supervisor never fight the OOM killer.

llm/parviz-llm.service, local LLM chat (llama-server)

Persistent Tier-2 brain service (docs/AWARENESS.md): llama.cpp llama-server + Qwen3-0.6B-Q4_0, enabled on boot next to the face. Chat UI: http://moshe-pi5-2gb.local:8081/ (LAN), API at /v1/chat/completions (OpenAI-compatible). MemoryMax=1400M so a runaway LLM gets killed before it OOMs the face. Qwen3 thinks by default; add /no_think to the system prompt for snappy replies. Verified 2026-07-12: service + face active together, 1.1 GB still free.

brain/scenarios.py, simulated sensory input -> LLM behavior

The Tier-2 loop from docs/AWARENESS.md, runnable before any sensor exists: full-suite sensor SNAPSHOTs (mics/camera/mmWave/IMU/vibration/ env/sonar/cliff/touch) render to a compact text digest; the system prompt gives Parviz's behavior rules + a constrained action vocabulary (do_nothing / set_expression / look_at / say / move / log / escalate); 10 scenarios (idle, person approaches, voice command, picked up, cliff stop, loud noise, stuffy room, petting, escalation-worthy question, late night) run against llama-server. Output shape is grammar-enforced via response_format json_schema; results append to brain/results/<tag>.jsonl with per-scenario expect verbs for the upcoming behavior benchmark.

python3 brain/scenarios.py --host moshe-pi5-2gb.local:8081 --tag mytag
python3 brain/scenarios.py --scenario person_approaches --runs 5
python3 brain/scenarios.py --digest-only   # inspect digests, no LLM

Findings on Qwen3-0.6B (2026-07-12, results/ committed): - raw prompt: right instincts, broken JSON shape; json_object grammar + thinking template = empty replies. - schema-only: valid JSON but DEGENERATE (look_at twice for every scenario, no parameters). Grammar alone kills a 0.6B. - schema + 2 few-shot examples + chat_template_kwargs {"enable_thinking": false}: parameterized sensible actions (~6-10 s per decision). Residual 0.6B failures, the benchmark targets: copies the few-shot pan_deg=45 verbatim (looks RIGHT at a LEFT sound), never chooses escalate or say, occasional gratuitous look_at when idle.

brain/bench_report.py, side-by-side behavior report

Reads results/*.jsonl, scores every run (pass = all expected verbs, no forbidden; partial = some expected; fail = forbidden/none/broken JSON) and writes self-contained report.html: score matrix, then per scenario the exact digest + every model's actions/reason/latency side by side. python3 bench_report.py qwen3-0.6b llama3.2-1b lfm2-1.2b qwen3-1.7b. First 4-model run (2026-07-12): qwen3-0.6b 6P/3~/2F, lfm2-1.2b 5P/3~/3F, qwen3-1.7b 3P/3~/5F (over-passive do_nothing bias; answers the visual question itself instead of escalating), llama3.2-1b 3P/0~/8F (says something for nearly every scenario). Deterministic verb scoring is crude and favors the few-shot pattern; an LLM-judge pass is the next benchmarking step.

Prompt-format ablation (2026-07-12, user hypothesis "maybe the prompt is wrong for these models" -- partly confirmed): --prompt v2 = EVENT-first digest with explicit side hints ("from LEFT (pan negative)"), compact decision-guide system prompt, 4 diverse few-shots (incl. escalate + say). Results: v2 improved every metric it touched -- qwen3-0.6b 2F->1F and the left/right pan sign FIXED (+45 -> -45; v1's sign error was pure prompt anchoring), qwen3-1.7b 3P->5P. But NO model ever chooses escalate, even with an explicit escalate example: tiny models cannot tell "answerable from digest" from "needs outside capability". That is a capability gap, not a prompt gap, and the headline metric for model selection.

Face UX v3 (2026-07-12): micro-saccades + breathing (never freezes), 1.6 s boot curtain reveal with typed "PARVIZ // BOOT", DIM-orange HUD (corner brackets, "PARVIZ // " + blinking cursor, breathing heartbeat pip), chamfered touch ripples. Same 10% of one core. SIGUSR1 is ignored until the renderer's handler is up (a dump request during pygame init used to KILL the process, default USR1 disposition).

Demo mode (2026-07-12): --demo now loops 11 scripted SCENES on timed intervals (idle scan, person detected -> track -> hello, wake word -> listening, thinking, speaking, escalate "ASKING BIG BRAIN", cliff alert flash, petted, low power, sleep with shut-bar eyes + ZZZ, wake), each driving expression + the HUD status line ("SCENE // STATE" with animated dots). DEMO_SCENES/DemoDirector in face.py; the renderer's status field is the same hook the brain loop will use later. Run on the Pi: sudo systemctl stop parviz-face && cd parviz-sw && SDL_VIDEODRIVER=kmsdrm python3 face/face.py --demo --seconds 45; sudo systemctl start parviz-face

Face v4 (2026-07-12, user feedback rounds): eyes = TWO THIN OUTLINES (4px outer + 2px inset echo, GAP 11; the bold filled ring is gone) and more expressive: bottom-lid squint (happy is a squint, not a droop) and pupil dilation per expression (surprised = pinpoint, happy/touch = dilated). Eyes moved high (cy 0.365H); a rigid 3-segment MOUTH is back by user request (smile/frown polyline; chamfered-O when open, surprise/speaking) at 0.72H. Bottom TELEMETRY bar refreshed every 5 s: CPU temp + NET (wlan/eth operstate) + PWR AC real today, MIC/CAM/RDR show -- until the hardware is wired (AWARENESS.md suite). All still one hue, straight strokes, ~10% CPU.

Face v5 (2026-07-12): PUPILS rebuilt as iris ring + filled octagon core + BG glint notch, with hippus (dilation twitches on saccades). HUD got its own cool palette (user: ORANGE IS FACE-ONLY now): slate text, cyan graphs/accents, green/red states. Corner blocks: SYS top-left (CPU temp + load + mem, 48-sample cyan temp sparkline @2 s), NET top-right (dev, IP, uptime, 5-segment wifi signal bar from /proc/net/wireless), status line bottom-left (cyan cursor), sensor slots + green PWR AC + cyan heartbeat pip bottom-right. Telemetry class samples procfs/sysfs fast items @2 s, slow @30 s, degrades to '--'. TOUCH: per-eye gaze, each eye aims at the finger independently (renderer eases per eye), so a poke BETWEEN THE EYES goes cross-eyed + opens a silly o-mouth (user todo).

Face v6 (2026-07-12, user rounds): HUD went LIGHT-GRAY monochrome (orange = face only; three grays, red reserved for alerts), MEM sparkline joined CPU (fixed 0-100% scale, labels left), dedicated PWR block top- center: real PMIC numbers via vcgencmd pmic_read_adc (sum of rail VA = board watts, ~2.4 W idle, + core V + throttle state; OK = past undervolt since boot) with a watts sparkline; NET gained the SSID (iw dev wlan0 info, iwgetid is not on trixie). LLM DECISIONS show subtly bottom-center: the brain writes {"actions":[...],"reason":...} to /tmp/parviz_decision.json, the face polls mtime every 2 s and shows "verbs // reason" while <120 s fresh (first face<->brain IPC). Pupil GLINT rides the gaze (dot sits where the eye looks). Touch between the eyes = DERP per user ref image: near eye stares at the finger, other rolls up-out. BG is true (0,0,0); the residual glow is IPS backlight, so the service dims it to 120/255 at start (ExecStartPre tee -- sh does NOT glob redirection targets, tee args do). Settled CPU ~6% of one core.

Face v7 (2026-07-12): EMOTIONS +concerned/angry/sick (angry = the positive-tilt V + pinpoint pupils + frown; sick = droopy + queasy half-open mouth). PUPIL ANATOMY corrected per user: the orange octagon is the fixed-size IRIS, the black dot inside is the PUPIL, it DILATES (surprised ~11 px, happy ~25 px) and rides the gaze. DEMO now covers every case: all 12 expressions + 4 gaze directions + talking mouth-flap (SPEAKING modulates open per frame) + a BOOP scene that injects a synthetic between-the-eyes touch (derp + ripple) + INTRUDER/SAD scenes. CAMERA PREVIEW: 96x72 live window right-edge center (picamera2 lores 160x120 BGR888 at ~6 fps; fails soft to CAM --). Costs +30 MB RSS and ~6% of one core. NOTE: the face process now OWNS the camera; when the perception process arrives it takes ownership and the preview should consume ITS frames instead. Brain schema updated with the new emotion names (scenarios.py enum + prompts).

perception/perceive.py, YuNet vision daemon (2026-07-12)

Tier-1 vision per the user's benchmark research: OpenCV FaceDetectorYN (YuNet 2023mar ONNX, 232 KB, in models/) on picamera2 640x480 -> 320x240, 15 Hz loop. SOLE camera owner (the face's CamPreview now READS its /dev/shm frames instead of opening picamera2). Publishes atomically: - /dev/shm/parviz_vision.json: interaction state (person_present, conf, cx/cy robot-frame normalized, size, facing_camera heuristic, pan/tilt suggestion, raw det box, fps, infer_ms) - /dev/shm/parviz_preview.jpg: 160x120 @3 Hz for the face CAM window Measured on-device: ~16 ms inference, 189 MB RSS, live detection of a real face at 0.95 conf. Detection rate 1 Hz per user (15 Hz = 77% of a core, 1 Hz = ~10%), --hz overrides. The face CAM window is a bottom- left GHOST FEED (144x108, grayscale, alpha 110 into the black, scanlines every 3 px) with the detection box + raw numbers beside it. --bench N prints the research CSV; --image f.jpg one-shot test. parviz-perception.service (MemoryMax=400M) enabled on boot.

Face integration: idle eyes FOLLOW the detected person (VisionGaze, neutral expression only; touch and brain expressions win), CAM window draws the raw detection box + faint raw numbers (HUD_FAINT 11 px, user: subtle), CAM slot shows OK from frame freshness. DEMO TOGGLE: triple-tap the screen within 1 s toggles demo mode at runtime, OFF by default.

Pipeline stage 2 (2026-07-12, user's diagram): Camera -> YuNet -> CROP+ ALIGN (rotate on YuNet eye line, 1.7x box margin, 256x256) -> MediaPipe FACE LANDMARKER -> behavior. No mediapipe python wheel exists for cp313/aarch64, so the .task bundle's face_landmarks_detector.tflite (478 pts, in models/) runs directly via ai-edge-litert (pip, installed --user on the Pi); its confidence head is a LOGIT (sigmoid it). The bundled blendshapes model needs mediapipe's private 146-index subset, so expression SIGNALS are geometric from canonical facemesh indices: EAR (eyes_closed <0.16), mouth_open (13-14 gap / face height), smile (mouth width/jaw width + corner lift), brow_gap -> visible_expression label (neutral/happy/surprised/eyes_closed; visible, not felt). Measured live: landmarker 26 ms on top of YuNet 16 ms, still 1 Hz. Face BEHAVIOR: smile mirror -- person smiles (smile>0.5) -> Parviz goes happy, reverts when the smile goes (only if vision set it; touch/demo/brain win). Raw line under the cam feed now shows the label + smile score + lmk ms. Verified end-to-end on a live smile at the desk.

brain/brain.py + parviz-brain.service, THE LOOP IS CLOSED (2026-07-12)

The LLM is now the ONLY source of truth for behavior (user directive). brain.py ticks every 15 s (inference is 7-15 s; faster ticks ran the fanless Pi to 85C throttle): builds a digest of EVERYTHING (all vision features incl. raw signals + confidences + timings, cpu/mem/temp, time, an event ring of person arrived/left + expression changes + its own past actions), asks the local Qwen (v2 prompt/schema imported from scenarios.py), writes /tmp/parviz_decision.json, journals to brain/journal.log. The face EXECUTES decisions (set_expression / look_at -> eye gaze / say -> status line for 6 s) and the smile-mirror + person-following reflexes were REMOVED - the LLM sees those signals and decides. HEARTBEAT CONTRACT: decision-file mtime stale >45 s (brain or LLM down) -> the face puts itself to sleep; verified by killing both services (face went sleepy alone) and recovery through llama-server restarts (rides the 500/503s). Eyes sized down (188x158) so the HUD reads clearly. THERMAL NOTE: sustained brain ticking holds the SoC near 80-85C on the bare board - a heatsink/active cooler belongs on the buy list before longer runs.

HUD layout v2 (2026-07-12, user: use the sides, label provenance): the side bands are now PANELS with hairline headers -- left "VISION" (ghost cam feed + detection box + every model value stacked: faces/conf, x/y, size, facing, looks+smile+ear, model timings), right "BRAIN" (LIVE/STALE + decision age, the LLM's action list, reason word-wrapped with ellipsis). Top row stays system (SYS/PWR/NET), bottom stays robot (status line, sensor slots, pip). The old scattered decision line + bottom-left cam block are gone; cam is 128x96 to fit the panel column. Self-reviewed via frame dumps; fixed text/eye clipping and truncation.

Prompt v3 (2026-07-12, user: "LLM doesn't react to emotion/position"): audit found the model never used look_at with real coordinates, never mirrored emotion, and ignored person-left/late-night/hot-cpu. v3 fixes it with explicit priority rules (person present -> BOTH look_at using the digest's head-aim numbers AND set_expression matching their visible emotion; person left -> center + sad; late night -> sleepy; cpu >80C -> log) and 4 few-shots in the LIVE digest format demonstrating each. The digest's EVENT line now leads with the current person state (tiny models act on the top line). Verified live: look_at pan -11.6 vs actual -11.8 (real numbers, not few-shot copies) and the journal shows "They are smiling at me; smile back and keep eye contact" -- the emotion mirror through the LLM. Face executor gain fixed: +-35 deg = full eye deflection (/88 made tracking invisible). KNOWN TRADEOFF: LLM-only gaze tracking has the tick latency (10-15 s); smooth fast tracking would need a reflex, which the user explicitly removed in favor of LLM truth.

Gestures + tracking bypass + latency (2026-07-12, user round): - TRACKING BYPASS: person-following eyes are a fast reflex again; look_at REMOVED from the LLM vocabulary (v3 prompt states "you do NOT control gaze"), executor ignores stray look_at. LLM keeps expressions, say, log, escalate, move. - GESTURES: hands.py runs the gesture_recognizer.task internals via LiteRT (no mediapipe wheel): palm detector (192x192 SSD, anchors generated + decoded by hand), rotated crop, 21-pt hand landmarks, GEOMETRIC classification (open_palm/fist/thumbs_up/thumbs_down/ pointing/victory). Validated 3/3 on mediapipe reference images; ~85 ms per tick when a person is present. Gesture flows into the digest + VISION panel; v3 rule 1 maps gestures to reactions (wave -> happy + greeting). Verified live: "Person is neutral; showing victory." - LATENCY: cache_prompt=True keeps the ~1400-token system+few-shot prefix in llama-server KV cache -- cycles dropped 10-25 s to ~6-8 s (prompt ~2.5 s + gen ~4 s); reasons capped at 10 words; the BRAIN panel now shows "cycle X.Xs (prompt+gen)" prominently. - FACE: a grand rigid-line MUSTACHE (user: much bigger), bottom-band hairline separator, panel line spacing 14 px.

Expressiveness + optimization pass (2026-07-12): mustache removed (user). Why the face felt non-expressive: vision under-labeled (smile>0.5 was a high bar) and the fixed 15 s tick outlived the user's expressions. Fixes: smile threshold 0.4 + more sensitive formula, new "sad" label (corner droop), perception rate ADAPTIVE (2 Hz person present / 0.5 Hz empty desk), and the brain is EVENT-DRIVEN: polls vision every 1 s and ticks IMMEDIATELY on presence/expression/gesture change, 20 s heartbeat otherwise, 4 s cooldown floor (thermals). max_tokens 120. Verified live: victory sign -> "Person is showing victory; surprised" -> face went SURPRISED within one ~9 s cycle. Sustained load still parks the bare SoC at ~84 C: the active cooler is now the single biggest optimization available for this app.

Cycle/OS optimization audit (2026-07-12, user): found REAL issues -- (1) ACTIVE THERMAL THROTTLE: throttled=0xf0006, ARM pinned at 1.5 of 2.4 GHz; every stage runs ~40% slow. Software cannot fix this: BUY THE ACTIVE COOLER. (2) Memory pressure: 470 MB into zram swap; fixed with llama-server --flash-attn + q8_0 KV cache and service restarts -- used 1670->1040 MB, zram 470->69 MB, mem 82->51%. (3) Perception 44% of a core: hand pipeline now runs every OTHER tick (result carried so the brain's event key doesn't flap) -> 24%. (4) Load avg 5.4 on 4 cores: reduced by 2+3. DEMO MODE REMOVED entirely per user (DemoDirector, scenes, --demo, triple-tap; the brain is the only behavior source now). BRAIN panel cycle line + action lines truncate to panel width (text ran off-screen), panel margins widened. NOTE the demo removal initially deleted Telemetry/CamPreview/VisionGaze with it (face crash-looped; restored from git HEAD) -- deletion by text-range needs the range checked against class boundaries.

Stress tint + polish (2026-07-12): the FACE COLOR is now a health indicator -- orange drifts toward RED with the WORST of cpu temp (60-85C), load (50-100%) and mem (60-95%), eased at 1.2/s so it breathes. Verified live: at 85C throttle the whole face runs deep red. Executor de-dupes contradictory duplicate set_expression actions in one decision (last wins). HUD margins equalized: content sits 26 px from ALL four edges (top/bottom rows were 6 px tighter than the sides).

No-brain mode (2026-07-12, user): when the decision file goes stale the face enters a proper NO-BRAIN state instead of plain sleepy: new face-internal "dozing" expression (near-shut lids with a slow 0.7 Hz breathing drift, the LLM cannot select it), animated status "BRAIN STARTING..." (never had a decision since face start; LLM warming) vs "BRAIN OFFLINE..." (had one, lost it), and the BRAIN panel shows WARMING UP vs OFFLINE -> dozing (red). Wakes to neutral the moment decisions resume. Verified by killing brain+llm (dozed at 55 s) and recovery (caught the user waving within one cycle of restart).

Thermal cooldown mode (2026-07-12, user): a circuit breaker across all three daemons. brain.py and perceive.py watch the SoC temp (enter >=85C = the firmware HARD-throttle onset per raspberrypi.com docs; RAISED from 80C on 2026-07-13, user "it goes to cooling too often" -- the passive-heatsink board sits at 80-85C under normal brain load so the old soft-throttle threshold tripped constantly; resume <=80C hysteresis, was 70C which took minutes to reach off-load; PARVIZ_COOL_ENTER/EXIT env overrides for testing): while hot the brain stops ticking the LLM (writes /dev/shm/parviz_cooling as the state marker) and perception skips all inference (publishes {"cooling":true}). The face sees the marker and shows the cooldown state instead of no-brain dozing: face-internal "hot" expression (droopy lids, panting mouth oscillation), pale-blue SWEAT DROPS sliding from the brow line, "COOLING DOWN C..." status, amber "COOLING (paused)" in the BRAIN panel. Verified via the marker (visuals) and a lowered-threshold live run (real trigger + resume). At the 85/80 thresholds the breaker fires only when the firmware is actually hard-throttling; normal 80-85C brain load runs uninterrupted. The active cooler (task #1) would eliminate even those trips.

Eyes = camera (2026-07-12, user): when vision is OFF the face closes its eyes. VisionGaze tracks online (fresh vision json AND not cooling); a blind factor eases the lids fully shut when offline and reopens on frames (waking-eyes effect at perception boot). VISION panel states: "OFFLINE, eyes shut" (amber) vs "paused: cooling". Composes with cooldown: while cooling, vision is paused, so Parviz sweats WITH its eyes closed.

AirPods audio (paired 2026-07-12)

"Mohsen's AirPods" (14:98:77:F0:ED:40) are paired/trusted -> auto- reconnect. Default sink: bluez_output.14_98_77_F0_ED_40.1. Stack: pipewire+wireplumber user services with loginctl enable-linger moshe (survive reboot/no-SSH), libspa-0.2-bluetooth (A2DP; NOT installed by default), pulseaudio-utils for pactl.

THE lesson: on a busy-RF desk, BlueZ's default dual-mode discovery NEVER saw the AirPods (btmon showed zero packets from them during scans) while raw classic inquiry (hcitool scan) saw them fine. Fix = ControllerMode = bredr in /etc/bluetooth/main.conf + restart: classic- only discovery found them in 18 s. NOTE this disables BLE on the Pi (fine: sensors go through the Arduino). bluetoothd also runs with -E (experimental drop-in) from debugging; harmless, can be removed. Pairing UX: hold the case button (lid open) ~10 s PAST the green/amber status blink until the light PULSES WHITE; iPhone Bluetooth must be OFF or iOS re-captures them. audio/pair_airpods.sh re-pairs after a factory reset.