Skip to content

Fresh-install shakedown: installer, capture, video and retention fixes, plus a full doc audit - #17

Merged
ekstremedia merged 19 commits into
mainfrom
fix/fresh-install-polish
Aug 8, 2026
Merged

Fresh-install shakedown: installer, capture, video and retention fixes, plus a full doc audit#17
ekstremedia merged 19 commits into
mainfrom
fix/fresh-install-polish

Conversation

@ekstremedia

@ekstremedia ekstremedia commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Setting up sigerfjordcam-2 from a blank SD card put the whole path under audit: three correctness passes (capture daemon, video pipeline, storage) and a claim-by-claim review of every document, with everything confirmed fixed here. The camera this branch was validated on is live: 30 s captures, 4K daily video at 05:00, all tests green on the Pi itself.

Correctness fixes

  • Installer exit codes: the EXIT trap made --check/--dry-run/--uninstall exit 1 on success.
  • Overlay drew nothing when enabled bare: default position was a corner layout that requires content lines, logged at DEBUG; and the default font was PIL's 10px bitmap. Defaults are now top-bar + DejaVuSans-Bold; saves are atomic (in-place Image.save truncated the good frame on a failed encode); status.jpg swaps by rename instead of unlink+symlink.
  • Midnight/DST filing: dir, filename, sidecar and capture_timestamp came from four datetime.now() calls — a frame straddling midnight filed where the renderer never looks. One shared, timezone-aware timestamp; the DST fall-back hour no longer destroys rows (INSERT OR REPLACE on a repeated naive timestamp) or overwrites JPEGs.
  • A wedged ffmpeg ended daily videos forever, silently: TimeoutStartSec=infinity on a Type=oneshot unit never fails, and the next timer merges into the stuck job. Caps at every layer now; "no images" moved from exit 2 (shared with argparse usage errors, so a broken invocation reported nightly success) to exit 10.
  • Upload finder could ship yesterday's keogram as today's: the loose glob matched the end date in the previous day's _0500_to_ filename.
  • Retention: expiry now judged by the covered date in the filename (mtime doesn't survive clock steps on an RTC-less Pi, and re-renders reset it); patterns anchored to output.project_name (a bare *.mp4 swept a second camera's files); empty-dir removal scoped to what the prune emptied; with uploads configured, a missing queue DB refuses to delete instead of treating everything as unprotected.
  • Daemon resilience: failed Picamera2() opens leak an fd inside picamera2 — the retry-forever loop hit RLIMIT_NOFILE in ~8 h while systemd showed healthy; five consecutive failures now exit for a clean respawn. take_test_shot restores config in a finally (one busy camera used to disable metadata sidecars for the process lifetime). Stale seed rows (>20 intervals) cold-start instead; ceiling gain only trusted above the sensor's 1.1228 floor band. Backward clock steps re-anchor the grid; SIGTERM stops the slot sleep in ≤1 s; --test exits 1 on zero frames and no longer takes a minute.
  • Storage: connection context managers double-yielded on caller errors ("generator didn't stop after throw()" buried "database is locked"); db --prune reported pre-counts as deletions even when the DELETE failed.
  • Metering: measured 0.0 was falsy → cleared the underexposure flag during a blackout; the overlaid-JPEG disk fallback biased the loop bright; lores slice uses the size libcamera granted; output.quality now applies at capture (picamera2 encoded at its own 90 otherwise).

Behavior changes

  • Highlight protection defaults on (as CONFIG-REFERENCE always claimed; night stays exempt).
  • Image window is half-open — the 05:00:00 frame belongs to one video, not two.
  • cleanup_old_images.sh takes RASPILAPSE_IMAGE_DIR/RASPILAPSE_KEEP_DAYS (validated; 0 = off — under find -mtime +0 it meant "older than a day").
  • Dead keys removed with their DEFAULTS/reference entries: test_shot.frequency, video.filename_pattern, transition_mode.target_brightness.

Docs

Every file audited against the code. EXPOSURE.md rewritten around the ladder (its threshold/hysteresis/civil-twilight system no longer exists); TIMELAPSE_VIDEO.md brought to the shipped encoder and real CLI defaults; CONFIG-REFERENCE's 22 drifted values corrected/annotated and bitrate documented; newcam.md rewritten as the fresh-Pi quickstart validated by this very install; trixie noted; the raspi-config camera step (gone since Bookworm) removed; suite runs without matplotlib (importorskip; CI unaffected).

Verification

  • pytest: 1119 passed, 4 skipped on the Pi (plus new tests: seeding staleness/floor guards, retention anchoring/date-expiry/fail-closed, half-open boundary).
  • black --check and ruff clean (pinned black 25.11.0).
  • Live: rpicam-verified Camera Module 3, --test frame in the right path shape, service on the :00/:30 grid, and a same-evening cli.daily --no-upload render probed at 3840x2160@25 and served 200 by Apache.

Deferred (deliberately not in this branch)

  • Persisting the controller's commanded exposure (required_exposure column) would delete the ceiling-inference and its tolerance entirely — bigger schema change.
  • MemoryHigh/MemoryMax on the daily-video unit are inert on stock Pi OS (memory cgroup off) — documented in the template, left as-is.
  • The concat list NamedTemporaryFile leaks on SIGKILL (the OOM path); PrivateTmp= or writing under data/ would cover it.
  • CI matrix stops at Python 3.12 while trixie ships 3.13 (suite passes on 3.13 here).
  • video.organize_by_date stays default-false in code for existing installs; the example config now sets it true — flipping the code default is a compat call for another day.

🤖 Generated with Claude Code

https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3

Summary by CodeRabbit

  • New Features

    • Added date-based video organization, configurable retention, latest-image symlinks, and camera naming in overlays.
    • Introduced adaptive exposure based on measured brightness, with highlight protection and improved recovery.
    • Added Raspberry Pi OS Trixie support and expanded network watchdog and installation options.
  • Bug Fixes

    • Improved timestamp, daylight-saving, locale, camera retry, signal, and atomic file handling.
    • Added safer retention cleanup, database error handling, and ffmpeg timeout protection.
    • Clarified successful no-image exits and installation validation statuses.
  • Documentation

    • Updated installation, upgrade, configuration, exposure, overlay, weather, video, and troubleshooting guidance.

ekstremedia and others added 13 commits August 8, 2026 21:26
cleanup_staging() ended on [ -n "$STAGING" ], and bash takes the EXIT
trap's status as the script's -- so --check, --dry-run and --uninstall all
exited 1 on success, and anything gating on $? wrongly aborted. The if-form
returns 0 on the empty case.

UpgradeOldRaspilapse.md documented the bug as a trap to read around; it now
records the fix (and keeps the still-true note that --check only warns about
optional modules).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
RASPILAPSE_IMAGE_DIR and RASPILAPSE_KEEP_DAYS env overrides, documented as a
systemd drop-in in the unit template so a changed window survives reinstall
and uninstall. KEEP_DAYS is validated: non-numeric refuses to delete
anything, and 0 short-circuits -- under find -mtime +0 it meant "older than
24 hours", the opposite of "keep everything". A missing image directory is
a clean no-op instead of a set -e abort.

Also drops the unit's [Install] section: install.sh only ever enables the
timer, and the section contradicted the installer's own design note that the
batch services cannot be enabled directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
The date subdirectory, image filename, sidecar name and capture_timestamp
came from four separate datetime.now() calls: a frame straddling midnight
landed in yesterday's directory under today's filename, where
find_images_in_range() never looks, and a sidecar could carry a different
second than its image. One shared timestamp now covers all four -- and it is
timezone-aware, because during the DST fall-back hour a naive string repeats
and the database's INSERT OR REPLACE destroyed the first pass's rows. A
repeated wall-time filename now keeps both files under a _dst suffix instead
of overwriting.

Metering: a measured 0.0 was falsy under 'or 128' and read as "no
measurement", clearing the underexposure flag at the exact moment it
mattered (4.7x slower recovery from a blackout); the working target seeded
from the dead transition_mode.target_brightness key instead of
brightness_target.base; and highlight protection now defaults on, as
CONFIG-REFERENCE always claimed. The lores brightness reader slices the size
libcamera actually granted instead of assuming 320x240, a half-opened camera
is closed before the retry, and close() nulls the handle even when
picamera2's close raises -- a half-dead handle pinned the device and every
later open failed with "Camera in use".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
- Every failed Picamera2() open leaks a pipe fd inside picamera2; the loop
  retried forever while systemd showed healthy, hitting RLIMIT_NOFILE after
  ~8 hours. Five consecutive camera or decision failures now exit so
  Restart=always hands back a clean process. (A decision failure used to end
  the loop on the first transient -- with exit code 0.)
- take_test_shot restored controls and save_metadata only on success; one
  busy camera -- ordinary right after a teardown -- left save_metadata False
  for the process lifetime, nulling every later row's exposure columns and
  starving restart seeding. The restore is in a finally.
- Restart seeding: rows older than 20 intervals are not seeded at all (a
  night row seeded into a bright morning cost ~24 pure-white frames), and at
  the shutter ceiling the gain column is only trusted above 1.2 -- below
  that the sensor's 1.1228 floor is indistinguishable from a commanded 1.0.
- The grid re-anchors when the clock steps backwards (no RTC: fake-hwclock
  plus late NTP), instead of sleeping out the whole step; the slot sleep
  runs in 1 s slices so SIGTERM stops the loop now (PEP 475).
- --test shoots immediately, skips the trailing slot sleep, and exits 1 when
  no frame was captured -- it is an install check, and it always said yes.
- status.jpg swaps by atomic rename; unlink-then-symlink left a window with
  no file, 2880 times a day, under a webserver that polls it.
- With no lores metrics the controller holds its last measurement; the old
  fallback re-read the overlaid JPEG from disk and biased the loop bright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
A Type=oneshot start that never returns never fails: under
TimeoutStartSec=infinity one wedged ffmpeg left the unit 'activating'
forever, the next morning's timer merged into the stuck job, and daily
videos ended silently with nothing marked red. The unit now caps at 4h, the
renderer caps its own ffmpeg at 3h, daily.py caps the renderer subprocess,
and lock contention prints how long the holder has been at it.

"No images -- nothing to render" moves from exit 2 to exit 10: argparse
also exits 2 on usage errors, so a broken invocation reported nightly
success. The upload finder's loose globs are gone -- keogram*<date>*
matched the END date in yesterday's _0500_to_ filename, so a night whose
keogram failed uploaded the previous day's file as today's. The image range
is half-open (a frame exactly at 05:00:00 belongs to one window, not two),
concat list entries escape single quotes, and the dead _daily_ naming
branch -- which produced names neither the uploader nor retention
recognised -- is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
mtime is the wrong clock on an RTC-less Pi -- fake-hwclock plus a late NTP
sync stamps fresh renders with month-old times, and re-rendering an old day
reset its window. Files now expire by the date in their name, mtime only as
a fallback for undated names.

The patterns anchor to output.project_name: a bare *.mp4 swept up anything
parked in the web tree, including a second camera's videos. Empty-directory
removal is limited to directories this prune emptied. And with uploads
configured, a *missing* queue database now refuses to delete -- an absent
file is indistinguishable from a mispointed database.path, and answering
"nothing is protected" deletes the only copy of every day that never
uploaded. The database path resolves through get_db_path, so pruning no
longer depends on the caller's cwd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
The connection context managers yielded from inside their except: an
sqlite3.Error raised in the caller's body was thrown in at the yield, caught,
and answered with a second yield -- which contextlib reports as "generator
didn't stop after throw()", burying the real "database is locked". The
yield now sits outside the connect guard, in both database.py and upload.py.

db --prune reported its pre-count as deletions even when the DELETE failed;
it now reports rowcount, and only dry runs use the count. The toolbelt
upload test skips when requests-toolbelt is not installed, as on a lean
camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
overlay.enabled: true with no other keys drew nothing: the default position
was a corner layout, corner layouts need overlay.content lines, and the miss
logged at DEBUG. top-bar -- which has built-in content -- is now the default
position, the empty-corner case logs a WARNING that says what to set, and
the default font is DejaVuSans-Bold: PIL's built-in bitmap font ignores the
computed size and rendered 10px text on a 4K frame.

Image.save opens its destination w+b, so overlaying in place truncated the
good capture first -- an ENOSPC mid-encode left a partial JPEG that was then
symlinked, recorded and uploaded. The overlay now writes beside the target
and renames into place, and its re-encode quality fallback matches
output.quality (85, was 95 on the one path that bypasses the merge).

formats: setlocale restored from the environment rather than the previous
value (process-global leak) and a missing locale warned once per frame --
2880 journal lines a day; it restores in a finally and warns once. The
exposure formatter's 1-second boundary produced an 8-char "1000.0ms"
against " 20.0s" -- the exact sideways jump the fixed-width module exists
to prevent -- and its 1/Ns branch was unreachable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
Both graph test modules imported matplotlib at collection time and took the
whole run down with them -- on the lean install the docs themselves
recommend. importorskip instead; CI installs matplotlib and still runs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
CONFIG-REFERENCE promised "every setting, with its default" while
twenty-two values disagreed with the code. The header now says what the file
is -- a working camera's values -- and every behaviour-relevant divergence
names the code default in its comment (quality, retention windows,
organize_by_date, hdr, highlight speeds, the video_upload enabled-by-
presence trap). video.codec.bitrate, the only rate control the hardware
encoder uses, was undocumented; the /www/ overlay-data paths pointed at a
directory that exists on no machine.

Dead keys removed together with their DEFAULTS entries where present:
test_shot.frequency (never read), video.filename_pattern (video names are
hardcoded), transition_mode.target_brightness (inert since the dynamic
target). The example config drops reference_lux for brightness_target.base
and gains the settings everyone was missing: video.organize_by_date (code
default false files every video flat), retention pointers, symlink_latest
and overlay.camera_name shapes -- still under the drift tests' 120 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
EXPOSURE.md rewritten around the ladder: its mode-selection section --
thresholds, civil-twilight override, hysteresis -- described a system that
no longer exists, and its tuning table sent readers to dead keys.
TIMELAPSE_VIDEO.md brought to the shipped encoder (veryfast/crf 20/3
threads), the real CLI defaults (05:00, not 00:00/now), the real ffmpeg
argument order, real filenames, measured timings, and a retention section it
lacked. README/INSTALL: trixie supported, the raspi-config camera step is
gone since Bookworm, netwatch is listed with the other installer options,
cleanup also expires videos, 6-8 GB/day, newcam.md and UpgradeOldRaspilapse
join the doc tables. OVERLAY/WEATHER: the commands and placeholders that did
not exist (--overwrite, {night}, center) removed, fixed-width examples
padded as rendered, N/A semantics stated. CONTRIBUTING: the pre-commit hook
builds its own black from the pinned rev -- the PATH advice pointed at the
one thing that cannot be the cause. config/README quotes the error string
the code actually raises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
newcam.md described the pre-1.5.0 layout: src/auto_timelapse.py, installer
scripts that do not exist, a deleted config key presented as mandatory, the
wrong overlay key, links to documents that were never written. It is now the
fresh-Pi walkthrough validated on sigerfjordcam-2 today -- OS to packages to
config to services to a same-day video test -- with the disk budget, the
sparse-interval arithmetic, and the traps that actually bite. The sibling
repos compress to a pointer at the upgrade runbook.

CHANGELOG records this branch: the installer exit codes, the overlay that
drew nothing, the midnight/DST filing, the wedged-ffmpeg bound, the exit-10
contract, retention by covered day, the fd-leak exit, seeding guards, and
the documentation audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
request.save() encodes at picamera2's own default quality (90) unless the
options dict says otherwise, so output.quality only ever applied to the
overlay's re-encode -- and not at all with the overlay off. Set it where the
camera is configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 10c2cf43-64b8-4cf2-8f10-53e3d29e8f8c

📥 Commits

Reviewing files that changed from the base of the PR and between 234b8b0 and 0468305.

📒 Files selected for processing (18)
  • config/config.example.yml
  • docs/OVERLAY.md
  • newcam.md
  • raspilapse/camera/capture.py
  • raspilapse/camera/metering.py
  • raspilapse/cli/status.py
  • raspilapse/daemon.py
  • raspilapse/overlay/formats.py
  • raspilapse/overlay/render.py
  • raspilapse/overlay/sources/weather.py
  • raspilapse/video/daily.py
  • systemd/raspilapse-cleanup.service.in
  • tests/test_auto_timelapse.py
  • tests/test_capture_image.py
  • tests/test_daily_timelapse.py
  • tests/test_make_timelapse.py
  • tests/test_overlay.py
  • tests/test_weather.py
📝 Walkthrough

Walkthrough

The PR updates adaptive exposure, capture timestamps, overlay output, video rendering, retention, storage errors, installation behavior, configuration defaults, documentation, and related tests.

Changes

Raspilapse audit fixes

Layer / File(s) Summary
Capture and exposure control
raspilapse/camera/*, raspilapse/daemon.py, raspilapse/config.py, tests/test_auto_timelapse.py, docs/EXPOSURE.md
Capture timestamps are timezone-aware and reused for filenames and metadata. Exposure uses brightness_target.base, safer startup seeding, lores metrics, interruptible scheduling, grid-aligned retries, and bounded failures.
Overlay, metadata, and storage handling
raspilapse/overlay/*, raspilapse/storage/*, docs/OVERLAY.md
Overlay defaults, locale handling, formatting, atomic output replacement, database connection handling, and prune counts were updated.
Video rendering and retention
raspilapse/video/*, tests/test_*timelapse.py, tests/test_video_retention.py, docs/TIMELAPSE_VIDEO.md, scripts/cleanup_old_images.sh
Video ranges are half-open. FFmpeg and renderer operations have timeouts. No-images uses exit code 10. Retention uses project-scoped names, encoded dates, upload-queue protection, and bounded directory cleanup.
Configuration, installation, and services
README.md, config/*, docs/CONFIG-REFERENCE.yml, docs/INSTALL.md, newcam.md, systemd/*, CONTRIBUTING.md
Configuration examples and defaults were aligned with code. Raspberry Pi OS Trixie, camera detection, watchdogs, cleanup retention, service behavior, and upgrade procedures were documented.
Validation and optional dependencies
tests/test_db_graphs.py, tests/test_graph_solar_patterns.py, tests/test_upload_service.py
Tests now skip when optional Matplotlib or requests_toolbelt dependencies are unavailable.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fixes across installation, capture, video, retention, and documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fresh-install-polish

Comment @coderabbitai help to get the list of available commands.

The status display carried its own fallbacks -- "bottom-left", "default",
0.02 -- and reported settings the renderer was not using. Mirror render.py's
defaults instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
raspilapse/daemon.py (1)

1151-1157: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset the failure count only after a successful frame.

Line 1157 clears consecutive_failures after camera initialization. If capture_frame() then fails on every slot, the next successful initialization clears the count again. The daemon can log failed captures forever without reaching the five-failure restart threshold.

Increment the counter in the frame-capture exception path. Reset it only after capture, observation, and recording complete.

🤖 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 `@raspilapse/daemon.py` around lines 1151 - 1157, Update the timelapse flow
around camera initialization and the frame-capture exception handling: remove
the consecutive_failures reset from the successful ImageCapture initialization
block, increment it when capture_frame() fails, and reset it only after capture,
observation, and recording complete successfully.
tests/test_daily_timelapse.py (1)

444-461: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the no-images test documentation.

The docstring says exit code 2 means no images. The test and raspilapse.video.timelapse.main() now use exit code 10.

🤖 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_daily_timelapse.py` around lines 444 - 461, Update the docstring
in test_main_no_images_exits_zero to state that exit code 10 from make_timelapse
indicates no images and is not a failure, matching the mocked return code and
timelapse.main behavior.
raspilapse/video/daily.py (1)

159-179: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the lock-holder timestamp.

Every contending invocation opens daily.lock with "w" before flock(). That updates its mtime. Line 173 then reports the contender's new mtime, not the holder's start time.

Open the file with "a+". Write or refresh the timestamp only after this process acquires the lock.

Proposed fix
-    lock_file = open(lock_path, "w")  # noqa: SIM115 -- must outlive this scope
+    lock_file = open(lock_path, "a+")  # noqa: SIM115 -- must outlive this scope
     try:
         fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+        lock_file.seek(0)
+        lock_file.truncate()
+        lock_file.write(f"{datetime.now().timestamp()}\n")
+        lock_file.flush()
🤖 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 `@raspilapse/video/daily.py` around lines 159 - 179, Update the lock-file
acquisition flow around fcntl.flock to open lock_path in append/read mode ("a+")
so contenders do not modify its mtime. After this process successfully acquires
the lock, refresh the timestamp, preserving the existing contention reporting
based on lock_path.stat().st_mtime.
🧹 Nitpick comments (1)
docs/TIMELAPSE_VIDEO.md (1)

196-205: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Specify the fenced-code language.

Line 199 opens a fenced block without a language. Use text for this sample output to satisfy MD040.

🤖 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 `@docs/TIMELAPSE_VIDEO.md` around lines 196 - 205, Update the fenced code block
containing the abridged example output in the timelapse documentation to specify
the text language, preserving the sample content unchanged.

Source: Linters/SAST tools

🤖 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 `@docs/OVERLAY.md`:
- Line 49: Remove the leading and trailing padding inside the inline code spans
in the table rows containing `{lux}` and the additionally affected rows 56–58.
Preserve the documented values while formatting each inline code span without
surrounding whitespace.

In `@docs/WEATHER.md`:
- Around line 70-72: Update WeatherData.format_fields() so missing readings use
N/A values padded to the same fixed widths as populated temperature and pressure
fields, preserving the bar’s layout; alternatively, revise the documented
fixed-width behavior to match the actual formatting.

In `@newcam.md`:
- Around line 151-153: Update the validation instructions around the expected
MP4, keogram, and slitscan outputs to make HTTP 200 checks conditional on web
serving being configured. When Apache/nginx is disabled, verify the files
directly instead; retain HTTP checks for all three outputs when a web server is
enabled.
- Around line 54-56: Update the optional dependency list in the installation
documentation to include python3-requests alongside python3-requests-toolbelt,
while preserving the existing matplotlib entry and descriptions.

In `@raspilapse/camera/capture.py`:
- Around line 483-493: Update the capture naming flow around output_path
collision handling and metadata generation so duplicate DST captures receive one
renderer-supported, collision-free identifier shared by the image and sidecar
files. Include an offset or DST-fold component in the timestamp-derived name,
use that same name when selecting/rendering images, and ensure metadata paths at
the logic around the sidecar generation do not reuse the unchanged local
timestamp or overwrite the first capture.

In `@raspilapse/daemon.py`:
- Around line 493-530: Initialize image_path immediately after constructing
metadata_dir in the test-shot flow, before metadata_dir.mkdir(), ImageCapture
initialization, or request capture can raise. Preserve the existing assignment
to the test shot path on success so the original camera or filesystem exception
propagates instead of being replaced by an UnboundLocalError.
- Around line 1083-1088: Update the initial wait in the daemon startup flow
around _next_slot to use the existing callback-enabled scheduler path instead of
direct time.sleep, passing a callback that observes self.running so SIGTERM
interrupts the wait. Preserve the test_mode behavior and the computed next-slot
delay.

In `@raspilapse/overlay/formats.py`:
- Around line 112-125: Add a module-level lock and wrap the entire LC_TIME
lifecycle in the locale-formatting function: reading the current locale, setting
locale_str, executing strftime, and restoring previous. Ensure all callers
serialize this process-global locale access while preserving the existing
formatting and restoration behavior.

In `@raspilapse/overlay/render.py`:
- Around line 920-935: The overlay save logic in apply_overlay must avoid the
shared “.tmp” path when concurrent calls target the same output_path. Create a
unique temporary file with tempfile.mkstemp() in the destination directory, save
the JPEG to that path, replace output_path with it, and retain cleanup of the
unique temporary path on failure.

In `@systemd/raspilapse-cleanup.service.in`:
- Around line 15-23: Document both cleanup overrides: in
systemd/raspilapse-cleanup.service.in lines 15-23, add an
Environment=RASPILAPSE_IMAGE_DIR=... example and state it must match
output.directory; in newcam.md lines 161-165, add the same image-directory
override to the retention instructions alongside RASPILAPSE_KEEP_DAYS.

---

Outside diff comments:
In `@raspilapse/daemon.py`:
- Around line 1151-1157: Update the timelapse flow around camera initialization
and the frame-capture exception handling: remove the consecutive_failures reset
from the successful ImageCapture initialization block, increment it when
capture_frame() fails, and reset it only after capture, observation, and
recording complete successfully.

In `@raspilapse/video/daily.py`:
- Around line 159-179: Update the lock-file acquisition flow around fcntl.flock
to open lock_path in append/read mode ("a+") so contenders do not modify its
mtime. After this process successfully acquires the lock, refresh the timestamp,
preserving the existing contention reporting based on lock_path.stat().st_mtime.

In `@tests/test_daily_timelapse.py`:
- Around line 444-461: Update the docstring in test_main_no_images_exits_zero to
state that exit code 10 from make_timelapse indicates no images and is not a
failure, matching the mocked return code and timelapse.main behavior.

---

Nitpick comments:
In `@docs/TIMELAPSE_VIDEO.md`:
- Around line 196-205: Update the fenced code block containing the abridged
example output in the timelapse documentation to specify the text language,
preserving the sample content unchanged.
🪄 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: 1764345e-f6ce-47bf-8d90-b302986b3c09

📥 Commits

Reviewing files that changed from the base of the PR and between cc46174 and 234b8b0.

📒 Files selected for processing (37)
  • CHANGELOG.md
  • CONTRIBUTING.md
  • README.md
  • UpgradeOldRaspilapse.md
  • config/README.md
  • config/config.example.yml
  • docs/CONFIG-REFERENCE.yml
  • docs/EXPOSURE.md
  • docs/INSTALL.md
  • docs/OVERLAY.md
  • docs/TIMELAPSE_VIDEO.md
  • docs/TROUBLESHOOTING.md
  • docs/WEATHER.md
  • newcam.md
  • raspilapse/camera/capture.py
  • raspilapse/camera/metering.py
  • raspilapse/config.py
  • raspilapse/daemon.py
  • raspilapse/overlay/formats.py
  • raspilapse/overlay/render.py
  • raspilapse/storage/database.py
  • raspilapse/storage/upload.py
  • raspilapse/video/daily.py
  • raspilapse/video/retention.py
  • raspilapse/video/timelapse.py
  • scripts/cleanup_old_images.sh
  • scripts/install.sh
  • systemd/raspilapse-cleanup.service.in
  • systemd/raspilapse-daily-video.service.in
  • tests/test_auto_timelapse.py
  • tests/test_daily_timelapse.py
  • tests/test_db_graphs.py
  • tests/test_graph_solar_patterns.py
  • tests/test_make_timelapse.py
  • tests/test_make_timelapse_daily.py
  • tests/test_upload_service.py
  • tests/test_video_retention.py

Comment thread docs/OVERLAY.md Outdated
Comment thread docs/WEATHER.md
Comment thread newcam.md Outdated
Comment thread newcam.md Outdated
Comment thread raspilapse/camera/capture.py
Comment thread raspilapse/daemon.py
Comment thread raspilapse/daemon.py Outdated
Comment thread raspilapse/overlay/formats.py Outdated
Comment thread raspilapse/overlay/render.py
Comment thread systemd/raspilapse-cleanup.service.in
ekstremedia and others added 5 commits August 8, 2026 21:42
Applied:
- The pre-loop grid alignment goes through the scheduler, so SIGTERM
  interrupts it like every other wait (it was the one bare time.sleep left).
- Overlay saves use mkstemp instead of a predictable .tmp: the daemon and a
  hand-run apply_overlay on the same frame could truncate or rename each
  other's half-written file.
- LC_TIME cycle serialized behind a module lock -- setlocale is
  process-global and not thread-safe.
- Missing weather readings pad to the same width as populated ones
  (temperature 7, wind 8, rain 7, pressure 8), so the bar keeps its width
  when a module drops out -- which is what WEATHER.md already promised.
- The DST-fold sidecar now carries the image's _dst suffix; the second
  pass's sidecar no longer overwrites the first's, and the
  {stem}_metadata.json pairing holds for the suffixed image.
- RASPILAPSE_IMAGE_DIR documented beside RASPILAPSE_KEEP_DAYS in the
  cleanup unit and newcam.md, with the match-output.directory warning.
- newcam.md lists python3-requests beside the toolbelt and makes the HTTP
  checks conditional on the webserver step.
- OVERLAY.md loses the padded code spans (MD038).

Answered on the PR: the test-shot UnboundLocalError claim (the exception
propagates; the return is unreachable on those paths) and the request for a
renderer-parseable duplicate identifier (any suffix or offset breaks the
frozen filename_pattern contract; the fold hour is preserved on disk and
excluded from the video by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
Ten one-line property accessors in metering and the weather cache were
undocumented, holding the PR's docstring coverage at 75% against the 80%
gate. Each now says what it answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
The atomic-save hardening switched the overlay's temp file to mkstemp,
which creates 0600 by design, and os.replace carried that mode onto every
final frame -- so the first camera to deploy it answered 403 for every
image the webserver was asked for, status.jpg included. fchmod the temp
file to 0644 before writing; a regression test now pins the mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
…ound

Seven new tests for the branch's defensive branches: the backward-clock
re-anchor, the SIGTERM-interruptible sleep slices, the five-consecutive-
failures exit (with no frame captured on the way), a failed camera open
releasing the half-opened handle, a failed overlay save leaving neither a
truncated frame nor a temp file, and the ffmpeg / renderer timeout paths.

Writing the lock tests found two real defects in the lock itself:
- A contender opened the lock file with "w", truncating the holder's file
  and re-stamping its mtime -- so the new holder-age report always said
  "~0 min". Contenders now open "a" and the holder stamps acquisition
  time explicitly; the age is finally the holder's.
- The suite shared the repo's real data/daily.lock across tests, and flock
  is per open-file-description -- whichever test's lock file happened to
  outlive its call (gc timing decides) blocked every later main() in the
  process. Each test now locks in its own tmp_path, and the suite stops
  writing into the repo's data/ directory at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uhRMmJgKzCJad6PNUihq3
@ekstremedia
ekstremedia merged commit 82f01f4 into main Aug 8, 2026
8 checks passed
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.

1 participant