Skip to content

[WIP] Set up and test the streaming camera#2

Draft
sgbaird with Copilot wants to merge 10 commits into
mainfrom
copilot/setup-streaming-camera
Draft

[WIP] Set up and test the streaming camera#2
sgbaird with Copilot wants to merge 10 commits into
mainfrom
copilot/setup-streaming-camera

Conversation

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown

Thanks for asking me to work on this. I will get started on it and keep this PR's description up to date as I form a plan and make progress.


This section details on the original issue you should resolve

<issue_title>Set up and test the streaming camera</issue_title>
<issue_description>Look at your custom instructions and the vast debugging thread in vertical-cloud-lab/byu-vcl#9 and any documentation in issues or PRs or the readthedocs for ac-dev-lab within AccelerationConsortium gh org

Make sure you use GitHub mcp or API so you can fetch even hidden comments

You have an AWS set of IAM credentials in your environment vars

You will have SSH access to tailscale for the streaming camera

There is already a lambda function available to that AWS account. The pickle token should already be there in a bucket. Be careful not to expose the lambda url or info regarding the bucket or Google token.pickle file in the S3 bucket.

Let me know if you're missing something that you need from me

You don't need to get this working with chalice right now. See AccelerationConsortium/streamingLambda#5 for additional context, you can do this manually for now</issue_description>

Comments on the Issue (you are @copilot in this section)

Copilot AI linked an issue Jul 3, 2026 that may be closed by this pull request
Copilot stopped work on behalf of sgbaird due to an error July 3, 2026 04:32
Copilot AI requested a review from sgbaird July 3, 2026 04:32
@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot I forgot to disable the firewall. Try again

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot I forgot to disable the firewall. Try again

Progress — the firewall change helped, but I'm now blocked by the Tailscale ACL, not the OS firewall.

What works now:

  • Brought the runner onto the tailnet (installed Tailscale, tailscale up with the OAuth secret + tag:tailscale-ssh).
  • The camera (******, tag tag:tailscale-ssh) is online and reachable — tailscale ping returns pong, and port 22 now responds (previously it would have timed out). So disabling the firewall did the trick at the network layer. 👍

Remaining blocker:

  • ssh $USER@$HOST.$TAILNET.ts.net fails with: tailscale: tailnet policy does not permit you to SSH to this nodeConnection closed.
  • This is a Tailscale SSH ACL issue. Both the runner and the camera carry tag:tailscale-ssh, but the tailnet policy has no ssh rule allowing a node with that tag to SSH into the camera as the camera user.

What I need from you (tailnet admin): add an ssh rule to the tailnet policy, roughly:

"ssh": [
  {
    "action": "accept",
    "src":    ["tag:tailscale-ssh"],
    "dst":    ["tag:tailscale-ssh"],
    "users":  ["<camera-login-user>", "autogroup:nonroot"]
  }
]

(Adjust dst/users to match your setup; the key point is that src: tag:tailscale-ssh must be permitted to SSH into the camera as its login user.)

Per the repo instructions, I'm stopping here rather than committing speculative changes since the task depends on SSH access. Once the ACL is updated, ping me and I'll retry immediately.

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot I updated the ACLs. Try again

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot I updated the ACLs. Try again

SSH works now — thanks for the ACL update. ✅ I connected as the camera user, confirmed the Pi (******, aarch64, kernel 6.12.75) and inspected the streaming setup. The camera is live-streaming to YouTube right now, but I found a concrete root-cause bug worth flagging before I touch anything.

The problem: two systemd services are both running device.py and racing for the single camera.

Both are enabled and start at boot at the same instant:

Service Python State Camera Result
picam-stream.service system /usr/bin/python3 active holds rpicam-vid (PID 1093) + ffmpeg (PID 1094) ✅ healthy live stream
device.service venv python active rpicam-vid dies instantly (<defunct>) ❌ broken restart loop

Because picam-stream.service grabs the camera first, device.service's rpicam-vid immediately exits (camera busy). Its ffmpeg then gets no video on pipe:0, misdetects it as an lrc subtitle stream, produces no video, and the service restarts every few seconds — it's burned 7h+ of CPU thrashing, and each cycle also calls the Lambda end+create (spawning throwaway YouTube broadcasts). That churn is almost certainly what the vast debugging thread was chasing.

Fix = run exactly one service. Two wrinkles I want your call on before making a destructive change on prod:

  1. The picam README.md documents device.service (venv) as the canonical one, but the service actually working right now is picam-stream.service (system python, and it has nicer hygiene: RuntimeMaxSec=8h auto-restart + SIGINT cleanup).
  2. This touches the ac-training-lab repo/host, not this streamingLambda repo, and each service streams to a different YouTube broadcast — so consolidating will change which broadcast is "the" live one.

My recommendation: keep picam-stream.service (it's the one currently working and has the safer restart config) and sudo systemctl disable --now device.service to stop the loop — this leaves the live stream uninterrupted. If you'd rather standardize on the README's device.service, I can do the reverse (disable picam-stream, restart device.service), but that briefly drops the stream.

Which service do you want as the single source of truth? Once you confirm, I'll apply it and verify the stream stays healthy. (I've kept the Lambda URL, RTMP stream keys, and bucket/token details out of this comment.)

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot switch to only doing device.service; also, pay attention to the AWS errors mentioned in the byu-vcl issue. That's the main thing I wanted you to diagnose and fix

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot switch to only doing device.service; also, pay attention to the AWS errors mentioned in the byu-vcl issue. That's the main thing I wanted you to diagnose and fix

Did both parts. One needs a sudo password from you; the AWS root-cause I diagnosed, verified, and have a ready patch for.

1. Switch to device.service only — blocked on sudo

I can SSH in fine, but the login user isn't allowed passwordless sudo and polkit rejects non-interactive systemctl (Interactive authentication required), so I can't disable the other unit myself. Per the repo instructions I'm not hacking around it. Two ways forward — your call:

  • You run these two lines on the Pi:
    sudo systemctl disable --now picam-stream.service   # stop the duplicate holding the camera
    sudo systemctl restart device.service               # let the canonical (README) service own the camera
  • …or add a NOPASSWD sudoers entry (or share the sudo password out-of-band) and I'll do it + verify only one rpicam-vid is running and the stream is healthy.

Note: device.service calls the Lambda create on startup to get its RTMP URL, so it only streams if the Lambda is healthy — which it now is (see below).

2. The AWS error — diagnosed, root cause found, fix verified

The specific crash (from byu-****** #9): Unable to import module 'lambda_function': .../cryptography/hazmat/bindings/_rust.abi3.so: cannot open shared object file → bare 502 from the function URL.

Root cause: deployment.zip was built with pip install on the Pi (ARM), so cryptography's compiled _rust.abi3.so is an ARM binary. AWS Lambda runs x86_64, so the dynamic linker can't load it and every invocation dies at import → 502. The build-deployment-zip.sh script + build-deployment-zip.yaml workflow (on the copilot/create-aws-zip-file-version PR) use a plain pip install --target … with no platform pinning, so the produced zip matches whatever machine ran it — fine on an x86_64 CI runner, broken whenever built on the Pi.

Proof (built both ways here):

build cryptography/.../_rust.abi3.so
plain pip install on ARM (the Pi) ELF … ARM aarch64 ❌ crashes on Lambda
--platform manylinux2014_x86_64 --only-binary=:all: ELF … x86-64 ✅ loads on Lambda

Good news — the live function is already healthy again. I health-checked it (harmless invalid-action request): it returns a proper 400 {"error":"Invalid or missing 'action'…"}, not a 502, and the camera is actively streaming — so whoever rebuilt/redeployed after that thread fixed the immediate outage. The remaining issue is that the build tooling will reproduce the bug on the next Pi rebuild.

The durable fix — pin the build to Lambda's platform so the zip is correct no matter where it's built (Pi included). In both build-deployment-zip.sh and the workflow, change the install to:

pip install \
  --platform manylinux2014_x86_64 \
  --implementation cp \
  --python-version 3.12 \   # must match the function's runtime
  --only-binary=:all: \
  --target ./dependencies \
  boto3 google-api-python-client google-auth google-auth-oauthlib google-auth-httplib2

(If you ever recreate the function as arm64, use manylinux2014_aarch64 instead and keep the function architecture matched.)

Those build files live in the separate copilot/create-aws-zip-file-version PR, not this branch. Want me to push this patch there (or cherry-pick it into this PR)? Say which and I'll commit it. I kept the Lambda URL, bucket, and token details out of this comment.

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot Cherry pick it. Also, I added RPI_STREAM_CAM_PASSWORD so you can sudo

…graphy

Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com>

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot Cherry pick it. Also, I added RPI_STREAM_CAM_PASSWORD so you can sudo

Both done (commit 26674ab), and switching to device.service surfaced the real AWS error.

1. Cherry-picked the build fix into this PR: build-deployment-zip.sh + .github/workflows/build-deployment-zip.yaml now pin --platform manylinux2014_x86_64 --implementation cp --python-version 3.11 --only-binary=:all:, plus the supporting lambda_function.py and a .gitignore for the build artifacts. Verified locally — the rebuilt zip now ships an x86_64 _rust.abi3.so (loads on Lambda). Pinned to 3.11 to match the documented function runtime.

2. Switched to device.service only with the new sudo password: sudo systemctl disable --now picam-stream.service + sudo systemctl restart device.service. picam-stream is now disabled/inactive; device.service is the sole enabled unit.

3. New root-cause AWS error — this is the one to look at. With device.service as the only owner, its startup create call to the Lambda fails:
500 … {"error": "Internal server error: An error occurred (403) when calling the HeadObject operation: Forbidden"}
So inside the Lambda, init_youtube_service()'s S3 download of the YouTube token gets 403 Forbidden. The import crash is gone (no more 502), so this S3 permission is the next blocker. Likely causes: the Lambda execution role (youtube-stream-dev) missing s3:GetObject on the token object, a bucket policy denying it, the object being SSE-KMS and the role lacking kms:Decrypt, or the object having been moved/deleted (a 403 masks a 404 when the role lacks s3:ListBucket).

I can't fix this from here: the env only has AWS console creds (AWS_IAM_USERNAME/AWS_IAM_PASSWORD), no access keys — aws sts get-caller-identityNoCredentials — so I can't inspect or edit the IAM role / bucket / KMS policy. To let me fix it, either drop programmatic access keys into the env, or grant the Lambda role s3:GetObject (+ kms:Decrypt if the token is KMS-encrypted) on the token object.

Heads-up on the live stream: because device.service can't complete create, nothing is streaming right now (I disabled the previously-working picam-stream per your instruction). device.service is Restart=always, so it should self-recover within ~10s once the S3 permission is fixed (may need one sudo systemctl restart device.service). Want me to temporarily re-enable picam-stream to keep the stream live while you sort the permission?

(Kept the Lambda URL, bucket, and token details out of this.)

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot switch back to the other service that was working, at least for now

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot switch back to the other service that was working, at least for now

Switched back as requested: sudo systemctl disable --now device.service + sudo systemctl enable --now picam-stream.service. picam-stream is now the enabled/only unit; device.service is disabled.

Important, though — this did not bring the stream back. On restart, picam-stream.service calls the Lambda create too, and it now fails with the exact same error device.service did:
500 … {"error": "Internal server error: An error occurred (403) when calling the HeadObject operation: Forbidden"}
So picam-stream is stuck in an activating/restart loop with no rpicam-vid running.

The reason it looked "healthy" before is that its previously running process had obtained a valid RTMP URL from a create call that succeeded before the S3 permission broke, and it kept streaming on that. The moment I restarted it (to switch back), it had to make a fresh create — which hits the 403. Both services are equally blocked by the Lambda's S3 token read; a service switch can't work around it.

Net: nothing will stream until the Lambda's s3:GetObject (+ kms:Decrypt if the token is SSE-KMS) on the token object is fixed. Once you fix that, picam-stream is Restart=always and should recover on its own within ~10s. Want me to keep it as picam-stream (current state) or flip back to device.service after the permission is sorted? (No code changes in this step — it's purely the systemd switch on the Pi.)

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

@copilot outline how I need to set up the programmatic access keys for you for this purpose

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

For provenance, I sent this within the copilot session:

Also, just making sure you're aware that the intention is to have YouTube store every 8 hour chunk ; this is the intention behind doing the restart, so if your changes cause YouTube to not leave each 8 hour chunk, then you need to revert what you did

Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com>

Copilot AI commented Jul 3, 2026

Copy link
Copy Markdown
Author

@copilot switch back to only using device.service, you can remove the other one. Make sure it's set up properly (see AC Dev lab read the docs / README on the corresponding branch).

...

Thanks for the clarification on the 8-hour chunking — that changes the correct fix, and I've acted on it.

Key realization: the RuntimeMaxSec=8h restart is intentional — it ends the current broadcast and creates a fresh one so YouTube stores each 8-hour segment as its own video. My earlier "idempotent create / reuse the same broadcast" change would have defeated that chunking. Per your instruction, I fully reverted it.

What I reverted:

  • Lambda (chalicelib/ytb_api_utils.py) — removed the broadcast-reuse logic; create again always makes a fresh broadcast. Rebuilt and redeployed (LastUpdateStatus: Successful).
  • device.py on the Pi — restored the original end_previous_broadcast() + create on every start (from the pre-patch backup).

What I kept (Task A) + hardened for chunking:

  • device.service is now the only unit (picam-stream.service removed).
  • Carried over the chunking hygiene that only picam-stream.service had, so 8h chunking survives on device.service: RuntimeMaxSec=8h, KillSignal=SIGINT (device.py catches KeyboardInterrupt → clean rpicam-vid/ffmpeg shutdown so the chunk finalizes), TimeoutStopSec=45. StartLimitBurst=3/hour stays as crash-loop protection (that loop — not the 8h restart — was the real source of the throwaway broadcasts).

Validated end-to-end: restarted device.service → it ran end (finalized the previous chunk) then create → a new video_id (different from the prior one), single rpicam-vid+ffmpeg, stream live. So each restart now correctly leaves a completed 8-hour chunk on YouTube.

Net repo change is documentation only: docs/ac-training-lab-picam-suggestions.md records the single-service + hardening recommendations for an upstream ac-training-lab PR, and now explicitly documents the 8h-chunk rationale so the restart isn't "optimized away" later. The device.py and systemd changes live on the ac-training-lab host (tracked in that doc, not this repo).

@copilot switch back to only using device.service, you can remove the other one. Make sure it's set up properly (see AC Dev lab read the docs / README on the corresponding branch).

...

Thanks — pointing me at the "Automatic startup" docs fixed my mechanism. The 8-hour chunking is meant to be a crontab reboot, not a RuntimeMaxSec on the service, so I've redone it the canonical way.

What I changed on the Pi to match the docs:

  • Restored device.service to the plain canonical unit (removed the RuntimeMaxSec=8h / KillSignal=SIGINT / TimeoutStopSec I'd added earlier — confirmed RuntimeMaxUSec=infinity, KillSignal=SIGTERM again).
  • Installed the documented root crontab for 8h chunking (timezone is America/Denver, so 5/13/21 are exactly 8h apart):
    # Restart at 5 am, 1 pm, and 9 pm local time for 8h YouTube chunks
    0 5,13,21 * * * /sbin/shutdown -r now
    There was no reboot cron present before, so chunking wasn't actually wired up canonically — it is now.
  • device.py keeps the original endcreate on startup, so each boot finalizes the previous chunk (Lambda end) and opens a fresh one (create).

Still in place from before: device.service is the sole unit (picam-stream.service gone); the Lambda idempotent-create/reuse change is fully reverted and redeployed, so create always starts a fresh broadcast.

Validated: single rpicam-vid+ffmpeg live, stream healthy, and an earlier restart confirmed a restart yields a new video_id (new stored chunk).

Repo change is docs-only: docs/ac-training-lab-picam-suggestions.md now documents the crontab-reboot chunking mechanism and explicitly warns against adding RuntimeMaxSec or making create idempotent (either would stop YouTube finalizing each 8-hour chunk), for the upstream ac-training-lab PR.

@sgbaird

sgbaird commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Also for provenance, I pinged:

Btw, the 8 hour chunking is meant to be implemented via crontab per https://ac-training-lab.readthedocs.io/en/latest/devices/picam.html#automatic-startup

Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com>
@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@copilot the current stream seems to be hanging. I'm thinking about using uptime robot or having an Internet speed test run on the Pi periodically. Thoughts?

Copilot AI commented Jul 6, 2026

Copy link
Copy Markdown
Author

@copilot the current stream seems to be hanging. I'm thinking about using uptime robot or having an Internet speed test run on the Pi periodically. Thoughts?

A few thoughts, since the two ideas catch different failure modes and one of them can backfire:

Why the stream "hangs" in the first place. The usual culprit here isn't the Pi going offline — it's that ffmpeg/rpicam-vid stall (RTMP socket half-dead, camera pipeline wedged) without exiting. Because the process is still "running," systemd's Restart=always never fires. So any monitor that only asks "is the Pi up?" will report green while the stream is dead.

UptimeRobot (pinging the Pi): weak for this. The Pi stays reachable during a hang, so a plain HTTP/ping monitor won't catch it. UptimeRobot only becomes useful if it's inverted into a heartbeat/push monitor (UptimeRobot has these; Healthchecks.io is a good free alternative): the Pi pushes a "still alive" ping only when the stream is actually healthy, and you get alerted when the heartbeats stop. That requires a real liveness check on the Pi (see below) — the external service is just the alerting layer.

Periodic speed test on the Pi: I'd avoid it. A full speedtest saturates the Pi Zero 2 W's uplink, which competes with the live RTMP upload on the same Wi‑Fi and can cause the very hangs you're trying to detect — you'd be measuring a problem you're creating. If you want network telemetry, use lightweight probes (periodic ping/latency + a tiny throughput sample), not a full-bandwidth test, and never during active streaming.

What I'd actually build (most robust): a small local watchdog that detects a stalled-but-alive pipeline and restarts device.service:

  • Parse ffmpeg's frame=/time= progress and confirm it's advancing (or check the RTMP TCP connection is still ESTABLISHED); if it's frozen for ~30–60s, systemctl restart device.service.
  • Optionally wire this into systemd via WatchdogSec= + sd_notify from device.py, or just a systemd timer/cron script.
  • Have that same watchdog emit the heartbeat to UptimeRobot/Healthchecks.io only on a healthy check — so external alerting and local self-healing share one signal.

Note this watchdog restart is compatible with the crontab-based 8h chunking (0 5,13,21 * * * reboot): a mid-cycle restart just runs endcreate like a boot does, finalizing the current chunk on YouTube.

Net recommendation: skip the periodic speed test; add a local stall-watchdog + heartbeat, and use UptimeRobot/Healthchecks.io in heartbeat mode for alerting. Most of this lives in device.py/systemd on the Pi (ac-training-lab), so I'd track it there for a proper upstream PR. Want me to implement the watchdog + heartbeat and validate it end-to-end?

@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@copilot sounds good on the watchdog restart. First, diagnose some of the logs on the Pi then implement

Copilot AI commented Jul 6, 2026

Copy link
Copy Markdown
Author

@copilot sounds good on the watchdog restart. First, diagnose some of the logs on the Pi then implement

I'm blocked on the "diagnose logs on the Pi" step: the Pi is currently offline on the tailnet, so I can't SSH in. This is itself a meaningful diagnostic finding.

What I see from my runner:

  • tailscale status****** … offline, last seen ~21m ago, and repeated pings show tx climbing but rx 0 (we send, it never replies).
  • tailscale ping times out; SSH dies at "banner exchange" on every retry over a ~5-minute window.
  • This is not a scheduled 8h chunk reboot — those are cron'd at 05/13/21 local (MDT), and right now it's ~08:5x MDT. So the Pi dropped off the network unexpectedly ~21 min ago and hasn't come back on its own.

That almost certainly is the "hanging" you're seeing, and it changes the design conclusion in an important way: a watchdog that only systemctl restart device.service from inside the Pi can't help when the whole Pi is off the network (hard hang, Wi‑Fi/tailscale drop, or power/USB brownout on the Zero 2 W). So the robust answer needs two layers:

  1. On-Pi stall-watchdog (frames not advancing / RTMP dropped → restart device.service) — catches the "process alive but stream frozen" case.
  2. Off-Pi heartbeat monitor (Healthchecks.io/UptimeRobot heartbeat, pinged only on a healthy check) — this is the piece that catches exactly today's failure, where the Pi is unreachable and no on-device logic can run. Optionally pair with a hardware/watchdog-timer reboot (the Zero 2 W's bcm2835_wdt via RuntimeWatchdogSec in /etc/systemd/system.conf) so a wedged Pi self-reboots.

I don't want to commit unvalidated watchdog code (it lives in device.py/systemd on the Pi in ac-training-lab, and I can't test it while the Pi is down). Could you power-cycle / bring the Pi back online? Once tailscale status shows it active again, I'll (a) pull journalctl -u device.service, dmesg, and Wi‑Fi/tailscale logs to confirm whether this was a hard hang vs. network drop, and (b) implement + validate the watchdog + heartbeat end-to-end and record the exact device-side changes in docs/ac-training-lab-picam-suggestions.md for the upstream PR.

@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Switching to use claude (copilot too expensive)

@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@claude check to see if the device is still offline. If you can connect again, carry on

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @sgbaird's task in 11m 17s —— View job


Pi is back online — diagnosed the hang, implemented + validated the watchdog ✅

  • Got on the tailnet (note: the OAuth client in my env is scoped to tag:stream-cam-test, not the tag:tailscale-ssh that copilot-setup-steps.yml requests — I worked around it by minting an auth key via the Tailscale API, but you may want to align the secrets/tags)
  • The Pi is back online — it recovered at the 13:00 MDT scheduled 8h-chunk cron reboot and has been streaming since
  • Diagnosed logs
  • Disabled Wi-Fi power save (live + persistent)
  • Enabled persistent journald
  • Installed + enabled the stall-watchdog
  • Validated the watchdog end-to-end with a controlled stall test
  • Documented all Pi-side changes for the upstream ac-training-lab PR (commit c183b2e)

Diagnosis of this morning's hang

  • Likely root cause: Wi-Fi power save was enabled at the driver level (brcmfmac: power save enabled in the kernel log) — the well-known Pi Zero 2 W failure where the Wi-Fi connection wedges until reboot. That matches exactly what happened: unreachable on the tailnet for hours, then back the moment the 13:00 cron reboot fired.
  • Supporting evidence it was a network drop, not a kernel hang: the hardware watchdog was already active (systemd, 1-min timeout), so a wedged kernel would have self-rebooted long before 13:00.
  • No undervoltage or throttling (throttled=0x0), no crash loops in the current boot.
  • Exact confirmation wasn't possible because journald was RAM-only — the 13:00 reboot wiped all logs from the outage window. Fixed (below) so the next incident is diagnosable.

What I changed on the Pi

  1. Wi-Fi power save off/etc/NetworkManager/conf.d/wifi-powersave-off.conf (wifi.powersave = 2) for persistence, plus iw dev wlan0 set power_save off live (verified: Power save: off), with no interruption to the stream.
  2. Persistent journaldStorage=persistent with SystemMaxUse=100M (bounded so it can't fill the SD card); journals now flush to /var/log/journal.
  3. Stall watchdog/usr/local/bin/stream-watchdog.sh + systemd timer (every 1 min): reads bytes_acked on the established RTMP TCP socket (ground truth that data is reaching YouTube). Three consecutive checks with no progress (or no socket) → systemctl restart device.service. A 180 s post-start grace period avoids false positives during end/create. This catches the "processes alive but stream frozen" case that Restart=always can't.
  4. Heartbeat hook (ready, not yet wired) — if you put HEALTHCHECK_URL=<heartbeat url> in /etc/default/stream-watchdog (Healthchecks.io or UptimeRobot heartbeat monitor), the watchdog pings it on every healthy check, so you get alerted for the one case no on-device logic can handle: the whole Pi dropping off the network. You just need to create the monitor and drop the URL in that file.

Validation (real stall test)

Froze ffmpeg with SIGSTOP: the watchdog logged 3 missed checks, restarted device.service, Lambda endcreate both returned 200, a fresh private broadcast was created, and data was flowing to YouTube again (~110 s detection + normal restart). This restart path behaves exactly like a boot (finalizes the current chunk, opens a new one), so it composes cleanly with the 8-hour cron-reboot chunking. Final state: service active, timer firing every minute, zero stall warnings since the test, ~40 KB/5s ACKed by the RTMP server.

Repo change: docs-only — commit c183b2e extends docs/ac-training-lab-picam-suggestions.md with sections 5–7 (power save, persistent journal, watchdog incl. full config) so these Pi-side changes can become a proper ac-training-lab PR. The doc also records the warning against periodic full-bandwidth speed tests (they'd saturate the Zero 2 W uplink and cause the stalls being monitored for). Kept the Lambda URL, bucket, stream keys, and video IDs out of this comment.
· Branch

@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

cc @seth-leavitt

…watchdog

Co-authored-by: Sterling G. Baird <45469701+sgbaird@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sgbaird

sgbaird commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@claude I added HEALTHCHECKS_IO_URL as a secret so that you can upload and use that.

not the tag:tailscale-ssh that copilot-setup-steps.yml requests — I worked around it by minting an auth key via the Tailscale API, but you may want to align the secrets/tags

Is there some change I should make to https://github.com/vertical-cloud-lab/streamingLambda/blob/main/.github/workflows/claude.yml (e.g., adding the tailscale github action similar to what's done in the copilot setup steps)? I don't think you as claude would use anything related to https://github.com/vertical-cloud-lab/streamingLambda/blob/main/.github/workflows/copilot-setup-steps.yml, and when I look at tailscale, it seems to be associated with the tag:stream-cam-test

Just to make sure, I want to avoid the possible situation where we'd end up with near-constant restarts and thus potentially hundreds of streams for very short amounts of time.

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @sgbaird's task in 11m 50s —— View job


Heartbeat blocked on one workflow line; restart-churn guard implemented + validated ✅

  • Gather context (env secrets, repo state, prior watchdog setup)
  • Get on the tailnet and SSH to the Pi (manual join via Tailscale API again — worked fine)
  • Wire HEALTHCHECKS_IO_URL into the watchdog — partially blocked: the secret never reaches my environment (details below); the Pi side is fully prepped so it's a one-line drop-in
  • Audit + harden restart-loop protection (the "hundreds of short streams" concern)
  • Answer the claude.yml / Tailscale question
  • Update docs + commit/push (commit 0d1dc4e)

1. The HEALTHCHECKS_IO_URL secret doesn't reach me yet

You added the secret at the repo level, but claude.yml never passes it into my environment — I checked, and my env has the Tailscale/RPi/AWS/Lambda vars only. Secrets aren't readable via the API, so I can't fetch it myself, and I'm not able to modify .github/workflows/ files (GitHub App permission limitation). Add this one line to the env: block of the "Run Claude Code" step in claude.yml:

          HEALTHCHECKS_IO_URL: ${{ secrets.HEALTHCHECKS_IO_URL }}

Everything else is ready: /etc/default/stream-watchdog now exists on the Pi with a commented HEALTHCHECK_URL= template, the watchdog already pings it on every healthy check once set (takes effect on the next 1-minute run, no restarts needed), and I verified the Pi can reach both hc-ping.com and heartbeat.uptimerobot.com. Once the line is in, re-ping me and I'll drop the URL in and confirm pings arrive at your monitor. (Or paste it into that file yourself over SSH — either works.) Suggested monitor settings: period 5 min, grace 10 min — pings arrive every healthy minute, and 10 min of grace means a successful watchdog self-heal (~7 min gap worst case) won't page you; only a failed one will.

2. claude.yml / Tailscale: yes, two small changes

You're right that I don't use copilot-setup-steps.yml — that job runs only for the Copilot coding agent. Today I join the tailnet manually: I mint an ephemeral, single-use auth key via the Tailscale API using the TS_OAUTH_CLIENT_ID/TS_OAUTH_SECRET env vars, tagged tag:stream-cam-test (that's the only tag that OAuth client is allowed to issue, and your ACLs already permit it to SSH to the camera — proven again today). That works reliably, so nothing is broken — but if you'd like it cleaner, add this step before "Run Claude Code" in claude.yml:

      - name: Connect to Tailscale
        uses: tailscale/github-action@v2
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:stream-cam-test

Note the tag must be tag:stream-cam-test to match the OAuth client's scope (Copilot's setup uses different secrets — TAILSCALE_OAUTH_CLIENT_ID/TAILSCALE_OAUTH_SECRET — with tag:tailscale-ssh; two clients/tags is fine as long as each workflow requests the tag its own client owns). Keep the TS_OAUTH_* env vars in place either way as a fallback. I can't commit this myself since it's a workflow file.

3. Restart churn: audited, and I added a hard budget

Good news first: the watchdog's entire history shows exactly one restart ever — the deliberate SIGSTOP validation test. Zero false positives; the stream has been healthy all afternoon (NRestarts=0, RTMP bytes advancing every check).

Three independent limits now stack, and every restart path is covered by at least one:

  1. Watchdog cadence — a restart needs 3 consecutive missed 1-min checks plus a 180 s post-start grace, so ~7 min minimum between watchdog restarts even in the worst case.
  2. New: watchdog restart budget — the watchdog now keeps a rolling-24 h history of its own restarts in /var/lib/stream-watchdog/ (survives the cron reboots) and refuses to restart beyond 6 per 24 h (MAX_RESTARTS_PER_DAY, overridable in /etc/default/stream-watchdog). Past the budget it logs "restart budget exhausted — holding off" and does nothing; since the stream is stalled, heartbeats have stopped, so your monitor pages you instead of the Pi thrashing.
  3. systemd start limitdevice.service has StartLimitBurst=3 per hour; any mix of crash-loop and watchdog restarts beyond that puts the unit into failed until the next cron reboot, and the watchdog stands down when the unit isn't active.

Net worst case ≈ 3 scheduled chunk broadcasts + ≤6 watchdog restarts + a few crash-loop starts per day — roughly a dozen broadcasts/day even if everything is on fire, not hundreds.

Validation of the new logic (no broadcasts were harmed): off-device unit tests of the prune/count logic, then a live on-Pi test with an isolated script copy and a pre-filled 6-entry history — it correctly logged the budget-exhausted message and did not restart device.service (start timestamp unchanged). Healthy path re-verified through two real timer-driven runs after install.

Repo change

Docs only — commit 0d1dc4e updates docs/ac-training-lab-picam-suggestions.md (§7) with the restart budget, the heartbeat template location, and the churn-bounds analysis for the eventual ac-training-lab PR. Script/config changes live on the Pi. Kept the hostname, RTMP key, Lambda URL, and password out of this comment.

--- · Branch

Co-authored-by: Sterling G. Baird <45469701+sgbaird@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sgbaird

sgbaird commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@claude I made those two updates to claude.yml, so you should have the secret and an already set up tailscale. Implement, check on how things have been going, report back

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude finished @sgbaird's task in 5m 27s —— View job


Heartbeat wired + verified; stream has been healthy — full report below ✅

  • Confirm HEALTHCHECKS_IO_URL reaches my environment (it does — the claude.yml change worked)
  • Confirm Tailscale is already connected via the new workflow step (it is — no manual join needed this time; SSH to the Pi worked first try)
  • Check how things have been going since the last session
  • Install the heartbeat URL into /etc/default/stream-watchdog
  • Verify heartbeat pings actually arrive
  • Document the changes + commit (a9417bf)

1. Both claude.yml updates work

The secret is in my env, and the Tailscale GitHub Action had me on the tailnet before I ran a single command — the Pi was reachable and Tailscale SSH worked immediately. Nothing further needed on the workflow side.

2. Heartbeat: implemented and verified

  • Wrote the URL into /etc/default/stream-watchdog (replacing the commented template), passing it via stdin so it never appeared in shell history or process lists. Locked the file to root:root mode 600, since anyone holding a ping URL can fake healthy pings.
  • Verified delivery from the Pi with the exact curl invocation the watchdog uses → HTTP 200.
  • The watchdog picks the config up on each 1-minute run (no restarts needed); it has since run with the new config, exit 0, on the healthy path — so pings are now flowing every healthy minute. On your Healthchecks.io check, set period 5 min, grace 10 min so a successful watchdog self-heal (~7 min worst-case gap) doesn't page you; only a real outage will.

3. How things have been going: genuinely well

  • Zero real stalls, zero watchdog restarts since everything was set up. The only "stall"/"budget exhausted" entries in the whole journal are the two deliberate validation tests from yesterday's sessions. Restart budget is at 0/6.
  • Both cron chunk-reboots fired exactly on time (13:00 and 21:00 MDT, confirmed via the now-persistent journal — that fix is already paying off). Current boot: device.service started at 21:00:32, endcreate both returned 200, new private broadcast created, NRestarts=0 since.
  • One interesting event, and the system handled it perfectly on its own: at 22:28 MDT YouTube dropped the RTMP socket (ffmpeg Broken pipe). device.py's internal retry loop recovered within one second — restarted rpicam-vid+ffmpeg and kept streaming to the same broadcast. No new YouTube video, no watchdog action, not even a single missed check logged. That's exactly the layering we wanted: transient drops absorbed silently; wedged pipeline → watchdog (fresh broadcast); dead Pi → heartbeat alert.
  • Right now: stream live, RTMP bytes ACKing steadily, no throttling/undervoltage (throttled=0x0), Wi-Fi power save still off, journald persistent.

4. One cosmetic upstream nit found

device.py's retry path doesn't wait() on the old rpicam-vid, leaving one <defunct> zombie process per internal retry (harmless, cleared at each reboot). Added it to docs/ac-training-lab-picam-suggestions.md as new §8 — along with the 22:28 event as production evidence of the retry layering — for your eventual ac-training-lab PR. That doc update is the only repo change (commit a9417bf).

Kept the hostname, RTMP stream key, ping URL, Lambda URL, and password out of this comment.
· Branch

Co-authored-by: Sterling G. Baird <45469701+sgbaird@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sgbaird

sgbaird commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@claude nice. Also, we need to update that description. This isn't at the Acceleration Consortium ; current reads like:

Live camera feed from office cam stationed in Toronto, ON at the Acceleration Consortium (AC).

https://acceleration.u...

Instead, would be:

Live camera feed from office cam stationed at the Vertical Cloud Lab @ BYU

https://github.com/vertical-cloud-lab

You don't have to restart the current stream necessarily, but I'd like it to reflect that in the next stream.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Claude finished @sgbaird's task in 3m 13s —— View job


Broadcast description updated to Vertical Cloud Lab @ BYU — deployed ✅

  • Locate where the broadcast description is set — it was hardcoded in chalicelib/ytb_api_utils.py (lines 102–106)
  • Update the description text in the Lambda code
  • Rebuild the deployment zip and redeploy the live Lambda
  • Verify the deploy (current stream left running, per your note)
  • Commit + push the code change (072be3c)

What changed

chalicelib/ytb_api_utils.py now builds the description as:

Live camera feed from {workflow_name} stationed at the Vertical Cloud Lab @ BYU

https://github.com/vertical-cloud-lab

(with workflow_name = "office cam" from the Pi's config, same as the title). The old Toronto/Acceleration Consortium text and acceleration.utoronto.ca link are gone.

Deploy + verification

  • Rebuilt deployment.zip with the pinned x86_64 build and confirmed the new description text and a correct x86-64 cryptography binary are inside the zip before uploading.
  • aws lambda update-function-code on the live function → LastUpdateStatus: Successful.
  • Post-deploy health probe (harmless invalid-action request) returns a clean 400 validation error — the function imports and runs fine on the new code. No broadcast was created by verification, so no throwaway stream churn.
  • Checked the Pi over Tailscale: device.service is active with NRestarts=0, still streaming on the broadcast it opened at the 21:00 MDT chunk reboot — the deploy didn't disturb it.

When you'll see it

The currently-live broadcast keeps the old description (as you said, no restart needed). The next create — at the next scheduled 8-hour chunk reboot, 05:00 MDT this morning — will open a broadcast with the new description, and every one after that.

Repo change is the one commit (072be3c, 3 lines in chalicelib/ytb_api_utils.py). Kept the Lambda URL, bucket, hostname, and credentials out of this comment.

--- · Branch

@sgbaird

sgbaird commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Noting quick aside: might be good to get the pickle file embedded as a secret in this gh repo and do some tests with the automated download, processing, and re-upload workflows.

The YouTube broadcast description was hardcoded to the Acceleration
Consortium in Toronto; this camera is stationed at the Vertical Cloud
Lab at BYU. Deployed to the live Lambda; the next broadcast (next 8h
chunk) picks it up.

Co-authored-by: Sterling G. Baird <45469701+sgbaird@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set up and test the streaming camera

2 participants