Finish the dusk step, and stop the capture loop paying for a reading it discards - #16
Conversation
The behaviour change is the entire purpose of this commit, and it re-records
the golden files. That is the bar tests/replay/README.md sets for running
record_golden.py, and the evidence is below.
seed_from_metadata reseeded the loop from the last daylight frame's *camera*
metadata. Metadata reports what the sensor did, not what was commanded, and
this sensor's analogue gain floor is 1.1228 where the ladder asks for 1.0 --
so _required was multiplied by 1.12 at every dusk. Nine consecutive nights in
this camera's database show it, 2026-07-29 through 2026-08-07:
00:23:14 200043 us brightness 109.9
00:23:44 224519 us brightness 118.4 x1.1223, one frame
Nothing needed seeding. The ladder is continuous and label() names a region
after the settings are chosen -- "No exposure decision consults it" -- so
_required already held the correct continuation. The colour half of this same
function was deleted for the same class of reason in b652d7f; this is the rest
of it.
Golden vs new, on compare.py's own metrics across all 17 sequences:
flicker (stops/frame) 0.06753 -> 0.06689 better or equal on every one
brightness error 30.15 -> 31.34 entirely the cold-start ramp
The brightness figure is not a regression in anything production does. Only
the seven *recorded* sequences changed, and they are exactly the ones with no
`seed` key, so they cold-start at 20 ms and climb. In the golden the seeding
teleported them to the recorded ground truth at the first crossing; a
controller has no such oracle. Excluding those first 20 frames the two agree:
crashing_light 79.97 vs 79.97, dusk_transition 5.70 vs 5.70. The daemon seeds
from the database at startup, so it never cold-starts this way.
mutation_check lost "overexposure critical" to this change, because the
overshoot the seeding produced was what used to reach that branch.
synthetic_overexposure_critical_edge replaces it deliberately; all 33 are
caught again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
`-r` sat after `-i`, which makes it an output option. Against a concat input
that is a constant-rate conversion rather than a declaration: the demuxer
inherits image2's implicit 25 fps and ffmpeg duplicates or drops frames to
reach the requested rate. Measured on ffmpeg 5.1.9 with 50 real source images
at `-r 30`:
-r after -i : 50 images -> 60 encoded frames
-r before -i : 50 images -> 50 encoded frames
At the configured `video.fps: 25` both rates agree and it is a no-op, so
nothing has been wrong in the delivered videos. It would have gone wrong the
first time anyone changed fps to make the day play slower, which is a
plausible next request.
Not `-framerate`: the concat demuxer has no such option and ffmpeg exits with
"Option framerate not found".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
Three fixes to the loop's timing and its startup state.
**The reference shot.** Taking it means tearing the running camera down and
opening a second one -- libcamera will not hold two -- and it happens between
the top of the loop and the capture, so that frame lands about three seconds
late and the next one reverts. Measured on 2026-08-06: 68 of the day's 2879
intervals off by two seconds or more from 30, hourly on the dot from
REFERENCE_MAX_INTERVAL_FRAMES plus a cluster either side of midnight from
REFERENCE_LADDER_STEP. In a 115-second video that is a 10% wobble in playback
speed roughly once a second, worst at dawn and dusk -- which is where the
camera's owner reports the timelapse feels choppiest.
On a camera with `fixed_colour_gains` the reading is then discarded:
_target_colour_gains prefers the configured value and never consults the
learned reference. _wants_reference_shot's own docstring already said so and
told the operator to set `test_shot.enabled: false` by hand. Nobody did. The
controller now answers that question itself, via `learns_day_wb`, which lives
next to the precedence it describes rather than being a second copy of it.
Cameras without a configured white point are unaffected.
**The capture grid.** `sleep = interval - (now - loop_start)` clamps at zero,
so an iteration that overruns its slot moves every later frame by the overrun,
permanently. Capture times now land on multiples of the interval since the
epoch: an overrun costs its own slots and no more, a restart resumes the
previous process's phase, and the tens-of-milliseconds-per-frame creep from
excluding the wake-up from `elapsed` is gone. This does not fix a frame that
runs late inside its own slot -- the old code recovered from those too. The
reference shot was the source of those, and it is gone.
**The restart seed.** `_seed_from_last_capture` passed the database's
analogue_gain column straight into the loop's state. That column holds what
the sensor reported; the state is in commanded units, and ladder.allocate
keeps gain at 1.0 until the shutter is at its ceiling while this sensor
answers 1.1228 regardless. In flight the constant is absorbed -- it is a
feedback loop and nothing reads it back -- but here it is read back as a
command. Measured at the restart on 2026-08-07 08:06:33:
08:06:14 604 us brightness 119.9
08:06:33 657 us brightness 126.2 <- 604 x 1.1228, on the sensor grid
08:06:44 604 us brightness 120.2
Same defect as the dusk seeding removed in the previous commit, once per
restart instead of once per dusk. The column is trusted only where the ladder
genuinely commands gain: at the shutter ceiling.
No golden files change. None of this touches an exposure decision the replay
corpus exercises.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
…changelog Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
Written while taking spjutvikacam from 2b6e0af to 1.5.0. The changelog does not mention the src/ -> raspilapse/ package rename, yet that is what makes every installed ExecStart a dead path -- the camera cannot start between the pull and re-running the installer. That belonged in writing before the next camera is upgraded rather than after. UpgradeOldRaspilapse.md covers the config keys that are now inert (reference_lux and civil_twilight_threshold are the two that will surprise you), the automatic v3 -> v6 database migration, the retention dry-run that has to happen before video.retention_days is ever set, and the NetworkManager precondition for --with-netwatch, which is a no-op-plus-reboot-loop on a dhcpcd Pi. It also records what the encoder change actually costs. Measured on the same 2877 frames: veryfast/crf20/3 threads runs at 1.6 fps against 0.9 for fast/crf25/2, but the daily video goes 316 MB -> 821 MB, well past the ~500 MB the changelog estimates. A coastal scene with moving water compresses worse than whatever that figure came from, so the receiving server's bandwidth is worth checking before the override is deleted. Four traps cost real time and are called out explicitly: install.sh --check exits 1 even when it prints "Ready to install", because its EXIT trap ends on a false test; an EPERM on /dev/media* is usually a confined shell rather than a wedged camera, and inherited seccomp means sudo does not lift it; sibling repos must be grepped for "from src" as well as src/ paths; and a re-encoded day will not re-upload without clearing its upload_queue row first. newcam.md was already written and had been sitting untracked since July. It is the other half of the same story -- build a camera, upgrade a camera -- so it is tracked here too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9G6CYxVkhKu4ahBGncWD3
📝 WalkthroughWalkthroughThe change removes exposure handover seeding, adds epoch-aligned capture scheduling, makes white-balance reference shots conditional, corrects FFmpeg input-rate handling, and updates replay coverage for exposure transitions and critical overexposure. ChangesExposure and timelapse behavior
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CaptureLoop
participant AdaptiveTimelapse
participant ExposureController
participant Camera
CaptureLoop->>AdaptiveTimelapse: request decision at absolute slot
AdaptiveTimelapse->>ExposureController: decide()
ExposureController-->>AdaptiveTimelapse: exposure decision and diagnostics
AdaptiveTimelapse->>Camera: capture using commanded settings
Camera-->>CaptureLoop: capture completes
CaptureLoop->>CaptureLoop: advance or skip elapsed slots
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
tests/replay/synthetic_sequences.py (1)
379-379: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDocument or derive the 254.0 brightness cap.
The cap is load-bearing.
scene_luminanceintests/replay/harness.pyrejects a frame whenbrightness >= SATURATED(line 126), which makesmeasured_brightnessNoneand stops the controller from observing. This sequence saturates from frame 15 onward, so the whole tail depends on 254.0 staying strictly belowSATURATED. IfSATURATEDis ever lowered to 254.0, the tail becomes unmeasured frames and the golden silently loses the converged behavior it records.Tie the cap to the constant, or state the dependency in the comment block.
♻️ Proposed change to derive the cap
Import the constant at the top of the file:
from tests.replay.harness import SATURATEDThen apply this diff:
- "frames": [frame(50, min(254.0, 80.0 * 1.09**i), exposure_us=1_000_000) for i in range(60)], + # One below SATURATED: at or above it scene_luminance() returns None + # and the controller stops observing, which would blank the tail. + "frames": [ + frame(50, min(SATURATED - 1.0, 80.0 * 1.09**i), exposure_us=1_000_000) + for i in range(60) + ],Run the following script to confirm the value of
SATURATEDand that no other sequence already imports it:#!/bin/bash # Description: Locate the SATURATED constant and check existing import patterns. fd -t f 'harness.py' tests | xargs rg -n '^SATURATED' -C 2 rg -n 'SATURATED' --type=py tests rg -n '^from|^import' tests/replay/synthetic_sequences.py🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/replay/synthetic_sequences.py` at line 379, Update the brightness cap in the sequence definition around the frames list to derive from the SATURATED constant imported from tests.replay.harness, while keeping the cap strictly below SATURATED so scene_luminance continues to produce measurements for the tail frames.tests/test_make_timelapse.py (1)
284-315: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd an FFmpeg integration test.
Create valid images, call
create_videowithout mockingsubprocess.run, and useffprobeto assert thatfps=30preserves the input frame count. Provisionffmpegandffprobein CI because the workflow does not install them explicitly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_make_timelapse.py` around lines 284 - 315, Replace the mocked subprocess test test_the_frame_rate_is_set_on_the_input_not_the_output with an FFmpeg integration test that generates valid input images, invokes create_video without patching subprocess.run, and uses ffprobe to verify the output reports fps=30 while preserving the input frame count. Update the CI workflow to explicitly install or provision both ffmpeg and ffprobe so the test runs in CI.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@raspilapse/camera/exposure.py`:
- Around line 367-390: Update the learns_day_wb policy and its callers so
reference learning is permitted only when the current mode is LightMode.DAY and
fixed_colour_gains are not configured. Ensure _wants_reference_shot() or the
equivalent decision path rejects transition and night modes before triggering
update_day_wb_reference(), and add coverage for both non-day states.
In `@raspilapse/daemon.py`:
- Around line 257-277: Update the ceiling classification in the restart seeding
logic around at_the_ceiling so a delivered shutter slightly below
self.exposure.max_shutter is still treated as ceiling exposure, using the
camera’s exposure quantization tolerance. Preserve seed_gain for gain-controlled
frames at that quantized ceiling and add a restart test covering a delivered
shutter just below the configured maximum.
- Around line 1064-1079: Update the initialization-failure recovery path near
the retry continue to advance next_slot using the same interval-grid logic as
the normal scheduling block before retrying, including skipping all elapsed
slots when recovery exceeds an interval; factor or reuse that logic to avoid
divergence, and add a regression test covering a failure lasting longer than one
interval.
---
Nitpick comments:
In `@tests/replay/synthetic_sequences.py`:
- Line 379: Update the brightness cap in the sequence definition around the
frames list to derive from the SATURATED constant imported from
tests.replay.harness, while keeping the cap strictly below SATURATED so
scene_luminance continues to produce measurements for the tail frames.
In `@tests/test_make_timelapse.py`:
- Around line 284-315: Replace the mocked subprocess test
test_the_frame_rate_is_set_on_the_input_not_the_output with an FFmpeg
integration test that generates valid input images, invokes create_video without
patching subprocess.run, and uses ffprobe to verify the output reports fps=30
while preserving the input frame count. Update the CI workflow to explicitly
install or provision both ffmpeg and ffprobe so the test runs in CI.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f09a1719-2110-45f8-851b-d4a2a6905a1d
📒 Files selected for processing (18)
CHANGELOG.mdraspilapse/camera/exposure.pyraspilapse/daemon.pyraspilapse/video/timelapse.pytests/replay/golden/bright_night.jsontests/replay/golden/crashing_light.jsontests/replay/golden/dawn_transition.jsontests/replay/golden/deep_dark.jsontests/replay/golden/dusk_transition.jsontests/replay/golden/night_underexposure_edge.jsontests/replay/golden/synthetic_overexposure_critical_edge.jsontests/replay/golden/very_bright_night.jsontests/replay/harness.pytests/replay/sequences/synthetic_overexposure_critical_edge.jsontests/replay/synthetic_sequences.pytests/test_auto_timelapse.pytests/test_exposure.pytests/test_make_timelapse.py
…ntroduced **The ceiling test rejected every real night frame.** `at_the_ceiling` compared the database's exposure column against `max_shutter` with `>=`, but that column records what the camera *delivered* and the camera under-delivers: a commanded 20 s comes back as 19999994 us. This database holds 62556 gain-controlled rows at that value and not one at exactly 20000000, so the test was false for all of them and a night restart would have been seeded at gain 1.0 -- up to six times too dark. That is worse than the daylight bug the check exists for, and it was introduced by the previous commit. Now compared with a 0.1% tolerance: 235x the worst under-delivery on record, and still far tighter than any ladder step. **`learns_day_wb` ignored the mode.** `update_day_wb_reference` drops every reading taken outside DAY -- AWB has nothing to meter in the dark -- but it did so on the far side of the camera teardown, so a camera without configured gains paid the full price and threw the answer away. Dusk is the worst case: the ladder crosses most of its range and REFERENCE_LADDER_STEP fires a couple of dozen times. Pre-existing, and squarely what this branch is about. **The camera-init failure path bypassed the grid.** It slept and `continue`d straight past the scheduling, so the retry fired the moment it succeeded rather than on a slot, and a failure lasting longer than an interval left the schedule behind without skipping the slots it had missed. Both paths now go through `_sleep_until_next_slot`. Also derived the synthetic sequence's brightness cap from `SATURATED` rather than writing 254.0 by hand -- scene_luminance() returns None at or above it, so a cap that ever met it would blank the sequence's converged tail silently. The generated fixture is byte-identical, so no golden re-record. Not taken: the suggestion to replace the mocked ffmpeg test with an integration test. The argument *position* is what regressed and the mocked test pins it exactly; the real-ffmpeg measurement is recorded in its docstring (50 images -> 60 frames with `-r` after `-i`, 50 before), and putting ffmpeg into CI to re-derive a constant is a bigger change than the finding warrants. 1147 tests, all 33 mutations still caught, each fix verified by reverting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
codecov flagged the three call sites in run() that reach the scheduling helpers. The helpers themselves were already unit-tested; what was not was that the loop calls them, keeps what they return, and does so on every path -- which is precisely the gap that let the camera-init branch `continue` past the schedule in the first place. Two bounded runs with the camera mocked, one clean and one where initialize_camera raises before succeeding. Verified by reverting the init path to its bare `time.sleep(min(interval, 5))`: the second test fails. Two things the harness has to get right, both of which cost a hang to find: frame_count is incremented inside capture_frame, so mocking that away leaves test_mode's exit condition unreachable and the loop spinning; and the stub now clears `running` after a bounded number of iterations, so a future change to that exit condition fails the assertion rather than hanging the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
CodeRabbit's docstring check reads the functions a PR adds, not the package -- raspilapse/ itself sits at 94.5%. Six of the ones added here had none: two test methods, and four helpers whose reasoning was in comments above them rather than inside them. The two nested stubs in particular are worth stating plainly, since both encode a trap that costs a hung test run to rediscover. Also picked up the two pre-existing reference-shot tests in the class this branch already edits, which the diff attributes here because the insertions moved their line numbers. Functions added by this branch: 34/34 documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
The old cameras carry a blind `sudo reboot` cron from before any watchdog existed, and nobody remembers what it was guarding. Now that the capture watchdog handles a stalled camera in about fifteen minutes, that cron is doing less than it used to -- but "less" is not an interval, so this adds the query that turns it into one. Bucketing missed-frame gaps by hour of day is enough: if they pile up in the hour the cron fires, the reboot is the main thing interrupting the camera. On spjutvikacam that was 14 of 20 events in 209 days, against roughly three unexplained glitches in seven months, with capture having run 93, 72 and 42 days straight and RSS flat at 89 MB. It moved to weekly on that evidence. Two things kept it from being dropped outright, and both are recorded here because they are easy to talk yourself out of: a blind reboot cannot clear a hard wedge, since cron does not run either -- this camera lost 56 hours in February while on a two-day cycle -- and with netwatch unusable on a dhcpcd Pi the cron is the last thing covering a wedged network. Also notes that day-of-month `*/7` is not weekly. It fires on the 1st, 8th, 15th, 22nd and 29th and then gaps three days, which is the sort of thing that looks right in a crontab and is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9G6CYxVkhKu4ahBGncWD3
…/raspilapse into fix-dusk-step-and-capture-jitter
The white-balance fix in 1.5.0 was correct, but it only covered the colour half
of the problem, and the frames that prompted this report were taken by the old
code. Three separate defects were behind what the camera's owner reported as
"colours still jumping" and "a bit choppy here and there".
The colour pop was already fixed — the daemon just had not restarted
Commit
b652d7freached the working tree at 2026-08-06 22:17. The capturedaemon had been running since the Aug-5 boot and did not restart until
2026-08-07 08:06, so the reported frames (
00_23_14/00_23_44— the overlayprints
%H:%M, so "00:23" is exactly two frames) ran the pre-fix code. Everyframe since that restart has been
mode='day', so no day→transition crossinghas occurred and the fix has still never executed in production.
Measured in the delivered 4K video, frame 2327 at t=93.1 s:
Luma is flat because
deflickercorrects luminance and only luminance. Thecolour step passes through untouched. Nothing in this PR changes that path;
it is listed here because it is half the report.
What was still live
1. The exposure stepped 12% at the same boundary.
seed_from_metadatareseeded the loop from the last daylight frame's camera metadata. Metadata
reports what the sensor did, not what was commanded, and this sensor's analogue
gain floor is 1.1228 where
ladder.allocateasks for 1.0. Nine consecutivenights (2026-07-29 → 08-07) show
200043 → 224519 µsin one frame, meanbrightness +8 to +11 points.
The ladder is continuous and
label()names a region after the settings arechosen — "No exposure decision consults it" — so nothing needed carrying across
the boundary.
_requiredalready held the correct continuation. The seeding isdeleted, along with
_seeded,transition_seeded,reset_seed_state,seed_exposure,_seed_gain/_seed_wb(both already dead), and the daemon's_seed_across_mode_change.seed_from_captureis a different function andstays.
2. The same mistake at every restart.
_seed_from_last_capturepassed thedatabase's
analogue_gaincolumn into the loop's state. That column holds theachieved gain; the state is in commanded units. Measured at the 08:06:33
restart:
The column is now trusted only where the ladder genuinely commands gain: at the
shutter ceiling.
3. The reference shot — this is the "choppy here and there". Taking it means
tearing the running camera down and opening a second one, and it happens between
the top of the loop and the capture, so that frame lands ~3 s late and the next
reverts. On 2026-08-06: 68 of the day's 2879 intervals off by ≥2 s from 30 —
hourly on the dot from
REFERENCE_MAX_INTERVAL_FRAMES, plus a cluster eitherside of midnight from
REFERENCE_LADDER_STEP. In a 115-second video that is a10% playback-speed wobble roughly once a second, worst at dawn and dusk, which
is where the choppiness was reported as worst.
On a camera with
fixed_colour_gainsthe reading is then discarded:_target_colour_gainsprefers the configured value._wants_reference_shot'sown docstring already said the path was "dead weight" and told the operator to
set
test_shot.enabled: falseby hand. The controller now answers that itselfvia
learns_day_wb, which lives next to the precedence it describes. Cameraswithout a configured white point are unaffected.
4. Capture cadence.
sleep = interval - elapsedclamps at zero, so aniteration that overruns its slot moved every later frame by the overrun,
permanently. Times now land on multiples of the interval since the epoch.
5.
-rwas after-iin the ffmpeg command, making it an output-sideconstant-rate conversion against the concat demuxer's implicit 25 fps. Measured
on ffmpeg 5.1.9 with 50 real images at
-r 30: 60 encoded frames after-i,50 before it. A no-op at the configured 25, and would have bitten the first
time anyone changed
video.fps. Not-framerate— the concat demuxer has nosuch option and ffmpeg exits.
The golden re-record
Removing the dusk seeding changes an exposure decision, so
tests/test_replay_golden.pygoes red by design.tests/replay/README.mdsetsthe bar for
record_golden.py: the commit's whole purpose is the behaviourchange, and something other than the tests says it is better. Checked rather
than assumed:
The brightness figure is not a regression in anything production does. Only the
seven recorded sequences changed, and they are exactly the ones with no
seedkey — they cold-start at 20 ms and climb. In the golden, the seeding teleported
them to the recorded ground truth at the first crossing; a controller has no
such oracle. Excluding those first 20 frames the two agree:
crashing_light79.97 vs 79.97,
dusk_transition5.70 vs 5.70. The daemon seeds from thedatabase at startup, so it never cold-starts this way.
mutation_check.pylostoverexposure criticalto the change — the violentovershoot the seeding produced was the only thing reaching that branch. Verified
against the pre-change tree that all 33 were caught before, added
synthetic_overexposure_critical_edgeto replace it deliberately, and all 33are caught again.
Deliberately not done
real, but
deflickeralready removes it: source 99th-percentile frame delta4.56% → 0.95% in the video, frames changing >5%: 21 → 0. The obvious fix —
damping the bright end — is recorded in
Meter.speed's docstring as alreadytried and "measurably worse", and simulation confirms the cycle's amplitude is
set by the sensor grid rather than the loop gain. A better approach exists
(closing the loop on the achieved exposure) and belongs in its own PR.
interval, so there is no motion blur and moving things teleport. Only more
frames or synthesised blur can change that, and the interval is staying at 30 s
for now.
Verification
daemon-level one fails with the production numbers: "the controller holds
0.22460968588399888 but handed the camera 0.2".
sortlandcam-3at 15:49. Capture clean, no warnings, frames onthe 30-second grid.
01:21) is the first day→transition crossing since the daemon picked up 1.5.0,
and so the first live test of both the colour fix and this one.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DmLb2SeiKE4hdQXptxhaja
Summary by CodeRabbit
Also carried on this branch
docs/upgrade-runbookwas merged in (a6fa0dc). It has no PR of its own, so itwould otherwise sit orphaned on a branch. It is documentation only — no source
files, and the diff against the code commits is two files and 814 lines of
Markdown.
UpgradeOldRaspilapse.md— taking a pre-1.5.0 camera to 1.5.0: whatbreaks, the config keys that went inert, the database step, which units to
install, and a rollback. Ends with a summary of the traps.
newcam.md— standing up a new camera Pi end to end, including thepython-reverb half that raspilapse alone does not provide (no live image and
no vitals without it) and the upload-retry unit that has no install script.