# Next Session: Resume Sunday Demo Phase 1 (Manual Calibration via Web UI)

> **Supersedes** `docs/NEXT-SESSION-SUNDAY-DEMO-FIX-V2.md` for the Phase 1+ resumption.
> V2 is still accurate for everything through Phase 0 pre-flight.
> **Read this first, then open `~/.claude/plans/polymorphic-riding-turtle.md` for plan details.**

## TL;DR

Session 52 got through all of Phase 0 (including the mid-session `look_around` sweep-bug fix with regression test and the power rewire verification) and got stuck on Phase 1 red calibration after 6 failed auto-tune iterations. Session 52 built a **web UI tool for manual ball-region selection** to replace the auto-detection — it's deployed but never actually used. This next session picks up with "grab frame → open UI → user drags bbox → calibrate."

**Time spent in session 52:** ~100 minutes. Wall-clock hard-stop was 3.5 h — still have ~110 min of budget if you resume today. If resuming tomorrow, reset the budget.

**Your physical state needed:** crouched at the car with the red ball visible to the camera (you can put it down between steps; the UI pipeline captures a single frame and then you have as much time as you need to drag a rectangle).

## What session 52 left in place

### Code changes (ALL LIVE, NOT COMMITTED)

1. **`services/telegram-bot/car_demo_handler.py`** — `_LOOK_SWEEP_HOLD_S = 1.0` constant + `asyncio.sleep(_LOOK_SWEEP_HOLD_S)` between the three sweep POSTs, plus structured logging (`passthrough_demo start/ok/http_error/connect_error/timeout/exception`, `car_demo callback: demo=X user=Y`, `car_demo dispatch: branch=X`). Silent `except Exception: pass` blocks around `edit_message_text` replaced with `logger.exception(...)`.
   **Live-verified:** Rajesh tapped Look Around, camera physically swept left→right→center over ~3s. ✓

2. **`services/telegram-bot/pending_handler.py`** — one new `logger.info("pending_handler dispatch: car_demo_menu file=%s", ...)` at the `car_demo_menu` branch. **Live-verified** firing in `/tmp/telegram-bot.log` at 08:11:44. ✓

3. **`services/telegram-bot/tests/test_car_demo_handler.py`** — new `test_passthrough_look_around_paces_sweep_with_sleep` regression test. Patches `asyncio.sleep` with an `AsyncMock` and asserts 3 sleeps between the 3 POSTs, each using `_LOOK_SWEEP_HOLD_S`. **Two-sided verified**: passes on fixed code, fails on backup code with the exact regression message. ✓
   Full suite: **43 passed** (was 42, +1 new).

4. **`scripts/lab_calibrate_pi.py`** — new `--cx`, `--cy`, `--mask`, `--no-mask`, `--min-pixels`, `--grab-only`, `--grab-path` flags; renamed `_sample_center` → `_sample_at` (accepts optional center); added `_sample_by_mask` with HSV + connected-components + size filter + largest-area selection; added `_save_annotated_mask` for contour overlay + centroid marker. **DID NOT converge on 6 attempts** — see iteration log below. The new manual-select flow uses `--grab-only` to capture a clean frame for the web UI, then `--no-mask --cx X --cy Y --crop R` to calibrate the selected region.

5. **`scripts/lab_calibrate_webui.py`** (NEW, ~180 lines) — tiny stdlib-only HTTP server on `127.0.0.1:8089`. Serves a single HTML page with a canvas overlay for mouse drag-select. On mouseup + Submit, POSTs `{cx, cy, crop, w, h}` to `/submit` which writes them to `/tmp/lab_calibrate_bbox.json`. **AST-parses clean; never actually run end-to-end yet.**

### Deploy state (CRITICAL — read before any action)

| Location | Path | State |
|---|---|---|
| Local repo | `services/telegram-bot/car_demo_handler.py` | Edited, **NOT committed** |
| Local repo | `services/telegram-bot/pending_handler.py` | Edited, **NOT committed** |
| Local repo | `services/telegram-bot/tests/test_car_demo_handler.py` | Edited, **NOT committed** |
| Local repo | `scripts/lab_calibrate_pi.py` | Edited, **NOT committed** |
| Local repo | `scripts/lab_calibrate_webui.py` | **NEW FILE**, **NOT committed** |
| Titan live | `~/workplace/her/her-os/services/telegram-bot/car_demo_handler.py` | Matches local (md5 `2fe4729c192195f8e029389c2771c163`), running in bot pid 3848393 |
| Titan live | `~/workplace/her/her-os/services/telegram-bot/pending_handler.py` | Matches local (md5 `d80983f86c72b79e842bc14fe8e1f2f3`) |
| Titan live | `~/workplace/her/her-os/services/telegram-bot/tests/test_car_demo_handler.py` | Matches local |
| Titan backups | `/tmp/_car_demo_handler_BACKUP.py`, `/tmp/_pending_handler_BACKUP.py`, `/tmp/_car_demo_handler_WITHFIX.py` | For rollback if needed |
| Pi live | `/tmp/lab_calibrate_pi.py` | Matches local, 253 lines, 15430 bytes |
| Pi backups | `/home/pi/TurboPi/lab_config.yaml.1775872932.bak` | Pre-session yaml backup |

**A `git pull` on Titan will OVERWRITE the car_demo_handler.py / pending_handler.py / test_car_demo_handler.py changes.** Commit them as the first action next session, or be prepared to rsync again.

### Power state (jumper-cap rewire — VERIFIED)

Session 52 confirmed via `vcgencmd get_throttled` + `vcgencmd pmic_read_adc`:
- `throttled = 0x0` (session 50 had `0x50000`)
- `EXT5V_V = 5.08396V` (180 mV above under-voltage threshold)
- `VDD_CORE_V = 0.7510V` (normal idle)
- Temperature 43.9 °C
- All internal rails nominal
- Total idle current ~1.5 A

**Do not change the power setup.** It's clean.

### Pi `/tmp` wipes on reboot

The Pi's `/tmp` is tmpfs or tmpfiles-cleaned on boot. Every Pi reboot destroys `/tmp/lab_calibrate_pi.py` — **always re-rsync before running any Phase 1/2/3 command.** Session 52 caught this the hard way after Rajesh rebooted mid-session for the jumper-cap rewire.

Add to pre-flight: `rsync -av scripts/lab_calibrate_pi.py pi:/tmp/lab_calibrate_pi.py` as step 0.

## The failed Phase 1 calibration attempts (don't repeat them)

| # | Command | Outcome | Lesson |
|---|---|---|---|
| 1 | `--wait 5 --dry-run` (default 120×120 box) | Ball not in frame — camera at ground level, Rajesh was standing | Camera is at 15-20 cm off floor; must crouch |
| 2 | Same, Rajesh crouching | Ball in frame but tiny (~15% of box); std devs huge (L=47, b=42) | 120×120 box too large for small ball |
| 3 | `--crop 15 --frames 20` (30×30 box) | Box landed on ball edge + background | a, b tightened (std <15) but L still 40+ |
| 4 | First mask mode, loose HSV thresholds | Mask caught everything red-tinted (164956 px = 54% of frame) | Saturation floor 100 too loose under red-tinted camera |
| 5 | Mask mode, tighter HSV (S≥180, H extended to 140) | Caught floor shadow band (33740 px), not ball | Camera's red tint creates large saturated background regions; "largest blob" is wrong |
| 6 | Size cap + saturation-ranked selection | Picked tiny 595-px blob at (158, 8) (top of frame) — all candidates hit S=255 ceiling, tiebreaker degenerate | Saturation ranking useless when everything saturates |

**Final state after attempt 6:** script was edited to use `max_pixels=30000` + "pick largest within size band" (not saturation), but never re-tested because we pivoted to the manual-UI approach.

**Do NOT try more threshold tuning.** The manual UI is the right escalation. Six auto-tune attempts already proved the search space is too sensitive to hand-tune interactively.

## Phase 1 resume procedure (the manual-UI path)

### Step 0 — Pre-flight (2 min)

```bash
# Is the Pi alive?
ssh pi hostname
ssh pi "systemctl is-active turbopi-server"
curl -s http://192.168.68.61:8080/health | jq '.status, .demo_mode, .demo_phase, .throttled'

# Power still clean? (regression check on jumper-cap rewire)
ssh pi 'vcgencmd get_throttled; vcgencmd pmic_read_adc | grep EXT5V_V'
# Expect: throttled=0x0, EXT5V_V around 5.08V

# Re-rsync calibration script (always, because Pi /tmp wipes on reboot)
rsync -av /home/rajesh/workplace/her/her-os/scripts/lab_calibrate_pi.py pi:/tmp/lab_calibrate_pi.py
ssh pi "chmod +x /tmp/lab_calibrate_pi.py"

# Stop server so /dev/video0 is free for calibration
ssh pi "sudo systemctl stop turbopi-server && sleep 2"
ssh pi "systemctl is-active turbopi-server"  # expect: inactive
```

### Step 1 — Grab a clean frame from the Pi (10 sec per attempt)

Rajesh crouches at the car with the red ball held in front of the camera. He can pick whatever comfortable position he likes — the ball doesn't need to be at the frame center.

```bash
# Tell Rajesh to hold the ball steady, then:
ssh pi "python3 /tmp/lab_calibrate_pi.py --grab-only --frames 20 --grab-path /tmp/lab_calibrate_frame_raw.jpg"
# Expected output:
#   Grabbing one averaged frame (20 samples) for manual selection…
#     wrote /tmp/lab_calibrate_frame_raw.jpg (640×480)

# Copy to laptop
scp pi:/tmp/lab_calibrate_frame_raw.jpg /tmp/lab_calibrate_frame.jpg
ls -la /tmp/lab_calibrate_frame.jpg  # expect ~60-80 KB
```

### Step 2 — Start the web UI server + open in browser (5 sec)

```bash
# Clear any stale bbox from a previous attempt
rm -f /tmp/lab_calibrate_bbox.json

# Start server in background
python3 /home/rajesh/workplace/her/her-os/scripts/lab_calibrate_webui.py &
WEBUI_PID=$!
echo "webui pid=$WEBUI_PID"

# Open in browser
xdg-open http://127.0.0.1:8089/
```

**Rajesh drags a rectangle around the red ball in the browser, clicks "Submit selection," sees the green "SUBMITTED" confirmation.** Then tells me: "submitted" or similar.

### Step 3 — Read the selection and calibrate (5 sec)

```bash
cat /tmp/lab_calibrate_bbox.json
# Example: {"cx": 297, "cy": 256, "crop": 38, "w": 76, "h": 82}

# Extract and run the REAL calibration (not dry-run this time)
CX=$(jq -r .cx /tmp/lab_calibrate_bbox.json)
CY=$(jq -r .cy /tmp/lab_calibrate_bbox.json)
CROP=$(jq -r .crop /tmp/lab_calibrate_bbox.json)
echo "Calibrating red at ($CX, $CY) crop=$CROP"

# Dry-run first to sanity-check the stats
ssh pi "python3 /tmp/lab_calibrate_pi.py red --no-mask --cx $CX --cy $CY --crop $CROP --wait 0 --dry-run"
```

**Expected healthy stats (dry-run):**
- `a` mean ≥ 170, std ≤ 15
- `b` mean > 130, std ≤ 15
- `L` std ≤ 20 (still noisier than a/b because of specular highlights on shiny ball — acceptable)

If stats look clean, confirm with Rajesh, then run the real command (drop `--dry-run`):

```bash
ssh pi "python3 /tmp/lab_calibrate_pi.py red --no-mask --cx $CX --cy $CY --crop $CROP --wait 0"
# Expected: "✓ Wrote red to /home/pi/TurboPi/lab_config.yaml"

# Verify the yaml
ssh pi "cat /home/pi/TurboPi/lab_config.yaml"
```

### Step 4 — Kill webui, restart turbopi-server, sanity-check health (5 sec)

```bash
kill $WEBUI_PID 2>/dev/null
ssh pi "sudo systemctl start turbopi-server && sleep 4"
curl -s http://192.168.68.61:8080/health | jq '{status, demo_mode, demo_phase, throttled}'
# Expect: ok, false, "idle", 0
```

### Step 5 — Repeat for BLUE and GREEN

Same procedure: grab frame (Rajesh swaps ball), open UI, select, calibrate. Each color takes ~2-3 minutes end-to-end once the pipeline is proven on red. Total Phase 1 time from resume: ~15-20 minutes if the first selection is clean.

After all three colors:
```bash
ssh pi "cat /home/pi/TurboPi/lab_config.yaml"
# All three ranges should be narrow (spread ≤ 50 per a/b channel)
```

## After Phase 1 — Phase 2 and Phase 3

Phase 2 (no-motor passthroughs — Look Around, What Do I See?) and Phase 3 (motor demos — Chase Ball, Obstacle Dodge, Traffic Cop, QR Navigator, Color Spotter) are blocked by `~/.claude/plans/polymorphic-riding-turtle.md` §2, §3. Not touched in session 52. Phase 2 is ~15 min, Phase 3 is ~2 hours.

**Phase 3 safety reminder** (`polymorphic-riding-turtle.md` §10): the Hailo safety daemon is DELIBERATELY DISABLED during demo subprocesses. There is NO automatic hand protection during Chase Ball / Obstacle Dodge / Traffic Cop. Rajesh must sit next to the phone with finger on 🛑 Stop Demo throughout Phase 3. Verbal briefing script for Sunday guests is in §10 — read it aloud before letting anyone else tap buttons.

**Plan bug to remember during Phase 3:** §5 state-machine guard loop (lines 400-411) has an incorrect `jq -e` predicate — it asks for `.phase=="idle"` and `.frame_grabber_healthy==true` but the real `/health` contract uses `demo_phase` and has no `frame_grabber_healthy` field. Use this corrected predicate instead:
```bash
.demo_mode==false and .demo_phase=="idle" and .safety_daemon_healthy==true
```

## Commit list for resume session

These should all commit together as a single feat/fix commit when Rajesh approves:

```
services/telegram-bot/car_demo_handler.py      # sweep fix + tracing logs
services/telegram-bot/pending_handler.py       # car_demo_menu dispatch log
services/telegram-bot/tests/test_car_demo_handler.py  # +1 regression test
scripts/lab_calibrate_pi.py                    # --grab-only, --mask, --cx/--cy flags
scripts/lab_calibrate_webui.py                 # NEW — manual selection UI
```

Suggested commit message:
```
fix(telegram): pace look_around sweep + tracing; add LAB manual-select UI

The telegram /look passthrough fired three POSTs in ~40 ms with no pacing,
so the Pi's servo setpoints overwrote each other before the camera could
physically travel. Net visible movement was 0. Add asyncio.sleep between
directions, a _LOOK_SWEEP_HOLD_S constant, structured tracing logs across
the passthrough + dispatch path, and a regression test that patches
asyncio.sleep and asserts 3 awaits between 3 POSTs.

Also: LAB calibration manual-select tooling. lab_calibrate_pi.py gains
--grab-only for capturing a clean frame without running calibration,
--cx/--cy/--crop for sampling at an explicit location, and a mask mode
that didn't pan out (left in place as --mask opt-in). New
scripts/lab_calibrate_webui.py is a stdlib-only localhost HTTP server
with a canvas drag-select for choosing the ball region by mouse, when
auto-detection under the camera's red tint proves too fragile.

43/43 tests pass in services/telegram-bot/tests/test_car_demo_handler.py
(added 1).
```

## Open questions for the resume session

1. **Is the web UI ergonomic on Rajesh's laptop?** It's untested end-to-end. If the browser doesn't render the canvas correctly, or drag-select feels awkward, we may need to add keyboard shortcuts or numeric input fields as a fallback.
2. **Does `xdg-open http://...` work in Rajesh's environment?** If not, he can just manually paste `http://127.0.0.1:8089/` into Chrome.
3. **Will the same `--cx/--cy/--crop` approach work for blue and green?** Almost certainly yes — they're the same pipeline with a different color target. But HSV threshold tuning isn't involved in the `--no-mask` path, so color-specific problems shouldn't arise.
4. **Should we commit the `_sample_by_mask` code we didn't actually use?** Argument for keeping: it's a reasonable foundation if someone wants to revisit auto-detection later. Argument for removing: it's dead code that never worked. Decide at commit time.

## Prompt for the next session (copy-paste)

```
Resume Phase 1 of the Sunday demo fix. Read
docs/NEXT-SESSION-SUNDAY-DEMO-FIX-V3-RESUME.md first — it has the full
context, the failed attempts log, the manual-UI calibration procedure,
and the commit list. Session 52 got through Phase 0, fixed the
look_around sweep bug with a regression test, and verified the
jumper-cap power rewire. Phase 1 auto-calibration failed 6 times;
we built scripts/lab_calibrate_webui.py as a manual-select tool but
never actually ran it end-to-end. Your job is to pick up at the "grab
frame → open web UI → drag rectangle → calibrate" flow for red, then
blue, then green. The `asyncio.sleep` sweep fix, the pending_handler
dispatch log, and the new regression test are deployed on Titan via
rsync but NOT committed — so either commit them as the first action
or rsync them back from local if someone pulled on Titan in the
interim. Pi power: throttled=0x0, EXT5V=5.08V (verified, don't change).
Pi /tmp wipes on reboot so always re-rsync lab_calibrate_pi.py first.
Plan file: ~/.claude/plans/polymorphic-riding-turtle.md. Sunday is
2026-04-12 (tomorrow).
```
