Files
sky-cam/sky-cam.conf
T
Claude 3fc77ab75e Expand obs window to full dark+moon period; label with illumination%
moon_phase.py: add sun_altaz() to compute sun altitude for dark-sky check

moon_phase_monthly.py:
- Replace fixed 22:00-23:00 window with dynamic dark+moon intervals:
  sample every 10 min across ±24h of the phase moment, keep blocks where
  sun < DARK_SKY_SUN_ALT_DEG and moon > MIN_ALTITUDE — covers the full
  night the moon is actually visible in a dark sky regardless of month
- First east frame with quality >= MIN_QUALITY in that window wins
- Illumination% computed at the detection time and shown in the image
  caption and Mattermost message
- Remove OBS_TIME_H/M; add DARK_SKY_SUN_ALT_DEG config var

sky-cam.conf: replace MOON_OBS_TIME_LOCAL with MOON_DARK_SKY_SUN_ALT_DEG=-6
and explain the sun-altitude threshold options

https://claude.ai/code/session_01HkTxpNSTWtViZzxbrbKytR
2026-05-01 22:32:16 +00:00

582 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# sky-cam.conf — master configuration for all sky-cam scripts
# =============================================================================
#
# This is the ONLY file you need to edit. After editing, run:
#
# ./install.sh # installs systemd timers (no root needed)
# ./install.sh --system # system-wide install (requires sudo)
#
# Re-run install.sh any time this file changes.
#
# -----------------------------------------------------------------------------
# MANUAL SYSTEMD SETUP (if you prefer not to use install.sh)
# -----------------------------------------------------------------------------
# After editing this file, generate the unit files:
#
# mkdir -p ~/.config/systemd/user
# cp systemd/*.service systemd/*.timer ~/.config/systemd/user/
#
# Then edit ExecStart= in each .service file to match your SCRIPT_DIR, then:
#
# systemctl --user daemon-reload
# systemctl --user enable --now sky-cam-sunrise.timer
# # one sky-cam-seasons-<cam>.timer per camera, e.g.:
# systemctl --user enable --now sky-cam-seasons-east.timer
#
# Check status:
# systemctl --user list-timers 'sky-cam-*'
# journalctl --user -u sky-cam-seasons-sunrise.service -f
#
# System-wide (replace --user with no flag, prepend sudo):
# sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now sky-cam-*.timer
#
# -----------------------------------------------------------------------------
# SCRIPT MAP — what each file does and when it runs
# -----------------------------------------------------------------------------
#
# You run / schedule:
# daily_sunrise_video.sh — daily at SCHEDULE_SUNRISE (via systemd, SUNRISE_CAM only)
# grabs images around sunrise, makes a short video,
# uploads it to Mattermost
#
# 4-seasons.sh <cam> — daily at SCHEDULE_SEASONS_<cam> (via systemd, per camera)
# makes yesterday's daily clip sized to its share
# of the matching Vivaldi movement's music duration;
# on the last day of a movement, auto-triggers
# montage-mvt.sh
#
# moon-track.sh — nightly at SCHEDULE_MOON_TRACK (SUNRISE_CAM only)
# detects the moon in each east night frame, crops
# a tracked sequence, stitches into an mp4
#
# moon-phase-monthly.sh — daily at SCHEDULE_MOON_PHASE (SUNRISE_CAM only)
# no-ops except on phase post-days, when it
# confirms east saw the moon during the
# collection window, fetches NASA SVS Dial-a-Moon
# for that exact UTC hour, renders the moon
# full-screen on a black background, and posts to
# Mattermost. Handles full moon, first quarter
# (waxing half), and third quarter (waning half).
#
#
# install.sh — run once at setup, and again if this file changes
# generates and installs systemd units from conf
#
# stitch-cameras.py — run manually when needed
# stitches north + sunrise into a panorama
# usage: python3 stitch-cameras.py north.jpg sunrise.jpg prefix [focal]
#
# Auto-triggered (do not run directly):
# montage-mvt.sh — called by 4-seasons.sh on last day of each movement
# concatenates all daily clips for the movement,
# speed-adjusts to match music, overlays attribution,
# sends completion notification; on last Autumn
# movement also triggers year-end-join.sh
#
# year-end-join.sh — called by montage-mvt.sh at end of Autumn Mvt 3
# concatenates all 12 movement montages into one
# Four Seasons year video, sends notification
#
# Helpers (called by the scripts above, not run directly):
# notify.sh — sends notifications via ntfy / email / Mattermost
# season_info.py — outputs astronomical season/movement/year for a date
# sunrise.py — outputs today's sunrise time (used by daily_sunrise_video)
# sunrise2mm.py — uploads the sunrise video to Mattermost
# sunrise_overlay.py — burns a timestamp overlay onto the sunrise video
#
# -----------------------------------------------------------------------------
# FILE DEPENDENCIES — who calls whom, and what to update if you rename a file
# -----------------------------------------------------------------------------
#
# sky-cam.conf (this file)
# ← sourced by every .sh script; read by sunrise.py, sunrise2mm.py
# → if renamed: update the 'source' line at the top of ALL scripts
# (don't rename it — just edit it in place)
#
# install.sh
# ← run manually; reads this conf
# → if renamed: update the note at the top of this conf file only
# generates → systemd unit files (sky-cam-*.service / .timer)
# and sky-cam-notify-failure@.service
#
# daily_sunrise_video.sh
# ← called by systemd sky-cam-sunrise.timer (SCHEDULE_SUNRISE)
# → if renamed: update install.sh (write_service call ~line 70)
# then re-run install.sh to regenerate the unit
# calls → sunrise.py, sunrise_overlay.py, sunrise2mm.py
#
# 4-seasons.sh
# ← called by systemd sky-cam-seasons-<cam>.timer (SCHEDULE_SEASONS_<cam>)
# → if renamed: update install.sh (write_service call ~line 85)
# then re-run install.sh
# calls → season_info.py, montage-mvt.sh
#
# montage-mvt.sh
# ← called by 4-seasons.sh on the last day of each movement
# → if renamed: update 4-seasons.sh (the exec line near the bottom)
# calls → season_info.py, notify.sh, year-end-join.sh
#
# year-end-join.sh
# ← called by montage-mvt.sh at end of Autumn Mvt 3
# → if renamed: update montage-mvt.sh (the exec line at the bottom)
# calls → notify.sh
#
# notify.sh
# ← called by montage-mvt.sh, year-end-join.sh
# also referenced in install.sh for the OnFailure= failure template
# → if renamed: update montage-mvt.sh, year-end-join.sh, install.sh
#
# season_info.py
# ← called by 4-seasons.sh and montage-mvt.sh
# → if renamed: update 4-seasons.sh and montage-mvt.sh (the eval lines)
#
# sunrise.py
# ← called by daily_sunrise_video.sh
# → if renamed: update daily_sunrise_video.sh
#
# sunrise_overlay.py
# ← called by daily_sunrise_video.sh
# → if renamed: update daily_sunrise_video.sh
#
# sunrise2mm.py
# ← called by daily_sunrise_video.sh
# → if renamed: update daily_sunrise_video.sh
#
# stitch-cameras.py
# ← run manually only; no script calls it
# → safe to rename with no other changes needed
#
# moon-track.sh
# ← called by systemd sky-cam-moon-track.timer (SCHEDULE_MOON_TRACK)
# → if renamed: update install.sh (the moon-track write_service block)
# calls → moon_detect.py (Python lib via heredoc), ffmpeg
#
# moon-phase-monthly.sh
# ← called by systemd sky-cam-moon-phase.timer (SCHEDULE_MOON_PHASE)
# → if renamed: update install.sh (the moon-phase write_service block)
# calls → moon_phase_monthly.py
#
# moon_phase_monthly.py
# ← called by moon-phase-monthly.sh
# → if renamed: update moon-phase-monthly.sh (the exec line at the bottom)
# calls → moon_phase.py, moon_detect.py, moon_composite.py, notify.sh
#
# moon_phase.py / moon_detect.py / moon_composite.py
# ← Python libraries used by moon-track.sh and moon_phase_monthly.py
# → if renamed: update moon-track.sh, moon_phase_monthly.py, README
#
# =============================================================================
# ── Install location ──────────────────────────────────────────────────────────
# Auto-detected from where this file lives — override only if you move the
# scripts to a different location after install.
SCRIPT_DIR="${SCRIPT_DIR:-$(dirname "$(realpath "${BASH_SOURCE[0]}")")}"
# ── Storage ───────────────────────────────────────────────────────────────────
# BASE_DIR: root folder where camera images are stored.
# Structure created automatically: BASE_DIR/<cam>/YYYY-MM-DD/HH-MM-SS.jpg
# Defaults to a 'data' subfolder next to the scripts; override to point at
# an external drive or network mount (recommended for production).
BASE_DIR="${BASE_DIR:-$SCRIPT_DIR/data}"
MOVIES_DIR="${MOVIES_DIR:-$BASE_DIR/movies}" # override if videos live on a different drive
MUSIC_DIR="${MUSIC_DIR:-$SCRIPT_DIR/music}"
AUDIO_DIR="${AUDIO_DIR:-$BASE_DIR/audio}" # root for all audio recordings; override in .env
# Structure: AUDIO_DIR/<cam>/YYYY-MM-DD/HH-MM-SS.m4a (ambient)
# AUDIO_DIR/sunrise/<cam>/YYYY-MM-DD/sunrise-audio.m4a (sunrise clips)
# ── Location & timezone ───────────────────────────────────────────────────────
# Used by sunrise.py to calculate sunrise time each day.
#
# To find your coordinates:
# Google Maps — right-click your location → the first item shown is "lat, lon"
# Or: maps.google.com, drop a pin, coordinates appear in the URL and sidebar
#
# ── Location & timezone ───────────────────────────────────────────────────────
# Set your real values in .env — do not put exact coordinates here since
# this file is committed to git. Add these three lines to your .env:
#
# LATITUDE=43.1234
# LONGITUDE=-76.5678
# TIMEZONE=America/Chicago
#
# Placeholder values below are used only if .env has not set them.
LATITUDE="${LATITUDE:-0.0000}"
LONGITUDE="${LONGITUDE:-0.0000}"
TIMEZONE="${TIMEZONE:-America/New_York}"
# ── Weather tagging (OpenWeather) ─────────────────────────────────────────────
# When enabled, ambient-record.sh queries OpenWeather's "Current weather"
# endpoint just before each 30-minute audio chunk and appends a one-word
# weather tag to the filename, e.g.:
# east-08-30-00-thunderstorm.m4a
# east-09-00-00-rain.m4a
# east-09-30-00-cloudy.m4a
# That way each chunk is labelled with what was happening DURING that chunk —
# a foggy dawn won't mislabel the sunny afternoon files.
#
# Tag vocabulary (fixed set — only conditions that change the audio):
# thunderstorm rain rainstorm snow snowstorm
# Clear / cloudy / fog / wind sound the same on the recordings, so those
# conditions fall through to the un-tagged filename.
#
# Setup:
# 1. Get a free API key: https://openweathermap.org/api
# (the free "Current weather" tier — 60 calls/min — is plenty)
# 2. Add to .env: OPENWEATHER_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
# 3. Confirm LATITUDE / LONGITUDE are set in .env (already needed by sunrise.py)
# 4. Flip WEATHER_ENABLED=true below
#
# The query uses the same LATITUDE / LONGITUDE configured above — one location
# for the whole installation; per-camera coordinates are not supported.
#
# Failure-safe: if the toggle is off, the key is missing, the network is down,
# or the API errors out, weather-tag.sh prints nothing and the recorder falls
# back to the un-tagged filename. ambient-record.sh never blocks on this.
#
# Manual test: ./weather-tag.sh (prints e.g. "cloudy" or nothing)
WEATHER_ENABLED=false
OPENWEATHER_API_KEY="${OPENWEATHER_API_KEY:-}"
# ── Cameras ───────────────────────────────────────────────────────────────────
# List every camera name here (space-separated inside the parentheses).
# Each name becomes a subfolder under BASE_DIR (images) and BASE_DIR/movies.
# To add a camera: add its name here, add its schedules below, re-run install.sh.
#
CAMERAS=(east north south) # add west here + uncomment its schedule below when ready
SUNRISE_CAM=east # which camera faces east (gets the sunrise video job)
# ── Schedules ─────────────────────────────────────────────────────────────────
# All times are HH:MM:SS (24-hour local time).
# install.sh reads these and generates one systemd timer per camera per job.
#
# SCHEDULE_SUNRISE
# When to START the sunrise video job. The script wakes up, calculates
# today's sunrise time, then WAITS internally until the capture window is
# fully finished before touching any images. Set this early enough to
# cover your earliest possible sunrise minus SUNRISE_PRE_MIN — 03:00 works
# year-round for most locations. The video is ready a few minutes after the
# capture window closes, not at this start time.
#
# SCHEDULE_SEASONS_<cam> (one per camera in CAMERAS)
# When to run the Four Seasons daily clip job. Processes YESTERDAY'S images
# (not today's) — always runs after midnight. On the last day of a season
# movement this automatically triggers the movement montage build.
# Space multiple cameras at least 30 min apart to avoid disk contention.
#
SCHEDULE_SUNRISE=03:00:00
# Moon jobs (see "Moon jobs" section further down)
SCHEDULE_MOON_TRACK=02:30:00 # nightly tracker — runs after midnight, before seasons
SCHEDULE_MOON_PHASE=09:30:00 # daily check; no-ops except on phase post-days
# Four Seasons daily clip — one per camera, staggered 30 min apart.
# Processes yesterday's images; on the last day of a movement auto-triggers
# montage-mvt.sh. Space cameras at least 30 min apart to avoid disk contention.
SCHEDULE_SEASONS_east=01:00:00
SCHEDULE_SEASONS_north=01:30:00
SCHEDULE_SEASONS_south=02:00:00
# To add west: uncomment below, add west to CAMERAS above, add CAM_RTSP_west to .env
#SCHEDULE_SEASONS_west=02:30:00
# ── Sunrise video tuning ──────────────────────────────────────────────────────
# Capture window around sunrise:
# Images from (sunrise - SUNRISE_PRE_MIN) to (sunrise + SUNRISE_POST_MIN)
# are included in the clip. Larger windows give more context; they are all
# speed-adjusted to fit SUNRISE_TARGET_SECS regardless of window size.
SUNRISE_PRE_MIN=70 # minutes before sunrise to start capturing
SUNRISE_POST_MIN=10 # minutes after sunrise to stop capturing
# Target length of the final sunrise video (seconds).
SUNRISE_TARGET_SECS=10
# Burn the local sunrise time vertically on the right side of the video.
# Set false to skip the overlay entirely (cleaner look, faster encoding).
SUNRISE_OVERLAY_ENABLED=true
# Opacity of the sunrise-time text (0.0 = invisible, 1.0 = fully opaque).
SUNRISE_OVERLAY_OPACITY=0.45
# Delete daily sunrise videos older than this many days (rolling window).
# Set 0 to keep forever.
SUNRISE_RETENTION_DAYS=10
# Delete unpinned sunrise posts from Mattermost older than this many days.
# Pinned posts are always kept regardless. Set 0 to disable Mattermost cleanup.
MM_SUNRISE_RETENTION_DAYS=8
# Output directory for --test runs. Kept separate from production sunrise videos.
# Test files older than DEMO_RETENTION_DAYS are deleted automatically.
DEMO_DIR="${DEMO_DIR:-$MOVIES_DIR/demoio}"
DEMO_RETENTION_DAYS=8
# ── Video encoding quality ────────────────────────────────────────────────────
# FFmpeg CRF: lower = higher quality / larger files. Typical range 1830.
# 18 ≈ visually lossless 23 = ffmpeg default 28 = smaller/lower
# H.265 (libx265) CRF is not directly comparable — H.265 CRF 22 ≈ H.264 CRF 26
# in visual quality but ~40-50% smaller file. Final montages use H.265;
# intermediates (deleted after use) stay H.264.
#
# ENCODE_PRESET: slow = better compression, same CRF quality, ~2× encode time.
# Use 'medium' on slower machines, 'slow' or 'slower' on fast ones.
# All encodes run at night so 'slow' is the right default.
#
CRF_SUNRISE=28 # daily_sunrise_video.sh — daily Mattermost upload (H.264)
CRF_SEASONS_RAW=28 # 4-seasons.sh — raw concat before speed-adjust (H.264)
CRF_SEASONS_FINAL=26 # 4-seasons.sh — final daily clip (goes into montage) (H.264)
CRF_MONTAGE=28 # montage-mvt.sh — intermediate concat + sped steps (H.264)
CRF_MONTAGE_FINAL=22 # montage-mvt.sh — final archive montage (H.265)
ENCODE_PRESET=slow # ffmpeg preset for all encodes (slow/medium/fast)
AUDIO_BITRATE=192k # montage-mvt.sh — music track on movement montages
# ── Frame validation (4-seasons.sh) ──────────────────────────────────────────
# Empty (0-byte) frames are always skipped. No config needed for that.
#
# SEASONS_GREY_STDDEV_MIN / SEASONS_GREY_DARK_FLOOR — optional bad-signal filter.
# Catches uniform-colour frames caused by a bad RTSP signal: solid green, grey, etc.
# These are valid JPEGs but contain no real image content.
# Uncomment both lines to enable. A frame is dropped only when BOTH are true:
# 1. pixel standard deviation (0255) < SEASONS_GREY_STDDEV_MIN (frame is uniform)
# 2. mean brightness (0255) > SEASONS_GREY_DARK_FLOOR (frame is not dark)
# Night frames are dark so they always pass even if stddev is low.
# Requires ImageMagick (convert). Start with these values and tune from there:
SEASONS_GREY_STDDEV_MIN=5 # skip if stdev < 5 (very uniform colour)
SEASONS_GREY_DARK_FLOOR=30 # …but only if mean brightness > 30 (not a night frame)
#
# Performance: ImageMagick is only called on files below SEASONS_LARGE_FRAME_BYTES.
# Files above that threshold are always good and skip the check entirely.
# Default 1 MB — adjust if your camera produces larger bad-signal frames.
#SEASONS_LARGE_FRAME_BYTES=1048576
# ── Montage attribution overlay ───────────────────────────────────────────────
# A translucent bar across the top of each movement montage, naming the
# music source ("The Four Seasons — Antonio Vivaldi ...").
# Appears for MONTAGE_ATTR_DUR seconds from the start, with ATTR_FADE-second
# fade in and fade out. FADE_DUR controls the video/audio fade in/out at
# the very start and very end of the montage.
#
MONTAGE_FADE_DUR=2.0 # video + audio fade in/out (seconds)
MONTAGE_ATTR_DUR=6 # attribution overlay total duration (seconds)
MONTAGE_ATTR_FADE=1 # attribution fade-in and fade-out length (seconds)
# ── Music loop multiplier ─────────────────────────────────────────────────────
# Short movements (especially Mvt 2 adagios) give each day only 3-5 seconds of
# screen time. Looping the music N times multiplies the per-day target so days
# are less compressed and more watchable. Per-movement overrides take the form
# MONTAGE_MUSIC_LOOPS_<Season>_<MvtNum>.
#
# Recommended starting point based on movement durations:
# Summer Mvt 2 (107s) → 3× Winter Mvt 2 (132s) → 3×
# Autumn Mvt 2 (152s) → 2× Summer Mvt 3 (159s) → 2×
# Spring Mvt 2 (171s) → 2× others 1× (already ≥10 s/day)
#
MONTAGE_MUSIC_LOOPS=1 # default for all movements
MONTAGE_MUSIC_LOOPS_Summer_2=3
MONTAGE_MUSIC_LOOPS_Winter_2=3
MONTAGE_MUSIC_LOOPS_Autumn_2=2
MONTAGE_MUSIC_LOOPS_Summer_3=2
MONTAGE_MUSIC_LOOPS_Spring_2=2
# ── Direct RTSP capture (replaces MotionEye / any NVR) ────────────────────────
# capture.sh pulls frames directly from each camera's RTSP stream.
# No NVR software required — just ffmpeg and the camera's stream URL.
#
# Per-camera RTSP URL: CAM_RTSP_<cam>=rtsp://user:pass@host:port/path
# Find the URL in your camera's web UI (usually under Network → Video → RTSP)
# or check your camera's manual.
#
# CAM_RTSP_<cam> goes in .env (see bottom of this file), not here.
# Seconds between captured frames (applies to all cameras).
# Lower = smoother timelapse + larger storage footprint:
# 10 s → 8,640 frames/day ~2.5 GB/day (default, good balance)
# 5 s → 17,280 frames/day ~5 GB/day (smoother motion)
# 2 s → 43,200 frames/day ~12 GB/day (very smooth, high storage)
# CAPTURE_STALE_SECS should always be at least 2× CAPTURE_INTERVAL.
CAPTURE_INTERVAL=10
# Per-camera overrides — uncomment and set to override the global value above.
# Useful when cameras serve different purposes (e.g. south records ambient audio
# so fewer frames is fine; north faces a busy scene and benefits from 5s).
#CAPTURE_INTERVAL_east=10
#CAPTURE_INTERVAL_north=10
#CAPTURE_INTERVAL_south=30
# Seconds without a new frame before the watchdog sends a stall notification.
# A recovery notification fires automatically when capture resumes.
# Must always be at least 2× the effective CAPTURE_INTERVAL for that camera.
CAPTURE_STALE_SECS=500
# ── Sunrise audio capture ──────────────────────────────────────────────────────
# Records the camera's RTSP audio stream during the sunrise window so that
# daily_sunrise_video.sh can mix in natural ambient sound (birds, rain, wind)
# at real speed, while the video plays as the sped-up timelapse.
# The audio file is deleted automatically after it is mixed into the video.
#
AUDIO_ENABLED=true # set true if your camera has a working mic
CAPTURE_AUDIO_BITRATE=96k # bitrate for the sunrise audio recording
# Audio is recorded for exactly SUNRISE_TARGET_SECS, centred on actual sunrise:
# floor(TARGET/2) seconds before, ceil(TARGET/2) after (odd second → post-sunrise).
# Clips are saved to AUDIO_DIR/sunrise/<cam>/YYYY-MM-DD/sunrise-audio.m4a.
# After mixing into the video the clip is kept for SUNRISE_AUDIO_RETENTION_DAYS, then deleted.
SUNRISE_AUDIO_RETENTION_DAYS=7
# ── Ambient audio library ──────────────────────────────────────────────────────
# Continuously records natural sounds (birds, rain, wind) from AMBIENT_CAMS to
# build a reusable audio library. Runs as a permanent systemd service alongside
# capture.sh. Camera audio is 8 kHz mono (phone quality) — enough for nature
# sounds and white-noise style playback.
#
AMBIENT_ENABLED=false
AMBIENT_CAMS=(south) # cameras to record from
AMBIENT_CHUNK_SECS=1800 # seconds per file (default 30 min)
AMBIENT_BITRATE=96k # AAC bitrate; 6496k is plenty at 8 kHz
# Retention — global default, then optional per-camera overrides.
# Uncomment and set per-cam lines to keep different cameras for different durations.
AMBIENT_RETENTION_DAYS=30 # global default; 0 = keep forever
#AMBIENT_RETENTION_DAYS_east=14
#AMBIENT_RETENTION_DAYS_north=30
#AMBIENT_RETENTION_DAYS_south=60 # keep south longer for nature sound library
# ── Moon jobs (nightly tracker + monthly phase close-ups) ────────────────────
# All moon jobs operate on the SUNRISE_CAM (the east-facing camera that already
# has a clear view of the eastern sky). They share three Python helpers
# (moon_phase.py, moon_detect.py, moon_composite.py) and one cached lunar
# reference texture downloaded by install.sh.
#
# moon-track.sh nightly batch — detects the moon in each frame,
# crops a 480×480 box around it, stitches the
# tracked sequence into an mp4. Output:
# $MOVIES_DIR/<cam>/moon-track/YYYY/YYYY-MM-DD-moon-track.mp4
#
# moon-phase-monthly.sh daily check; runs whichever phase composite is
# due today. Three phases handled:
# 🌕 Full Moon posts D + MOON_FULL_POST_DELAY_DAYS
# 🌓 First Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# 🌗 Third Quarter posts D + MOON_QUARTER_POST_DELAY_DAYS
# East acts as the WITNESS — it confirms the moon
# was actually visible in your sky during the
# collection window and supplies the timestamp.
# The image itself is a NASA SVS Dial-a-Moon
# render for that exact UTC hour, sized to fill
# the frame on a black background (like the look
# of a long-telephoto shot). Output:
# $MOVIES_DIR/<cam>/full-moons/YYYY-MM-full.jpg
# $MOVIES_DIR/<cam>/first-quarter/YYYY-MM-first-quarter.jpg
# $MOVIES_DIR/<cam>/third-quarter/YYYY-MM-third-quarter.jpg
#
# Honest-by-design: a 38-px white blob from a wide-field IP camera cannot be
# enhanced into crater detail. The image you see in Mattermost is a NASA
# render for the exact moment east captured the moon — the strongest possible
# match for what your sky actually looked like, with full real-physics
# crater shadows, libration, and phase. East's role is verifying that you
# had a clear view of the moon that night.
#
# Geometry caveat: east only sees the eastern sky. At first quarter the moon
# is up only from noon to midnight, transiting south at sunset, so east only
# catches it during DAYTIME. The brightness-based detector often fails on
# daytime moon shots — first quarter is best-effort. Full moon and third
# quarter both rise after dark and stay in east's view; those should land
# cleanly most months.
#
# Toggles (any can be disabled independently):
MOON_TRACK_ENABLED=true
MOON_FULL_ENABLED=true
MOON_FIRST_QUARTER_ENABLED=true # set false if daytime-detection misses are noisy
MOON_THIRD_QUARTER_ENABLED=true
# Set false to skip east verification entirely and post the NASA render for
# the exact phase moment regardless of clouds / camera outage. Default true
# means "only post when east actually saw the moon that month."
MOON_REQUIRE_EAST_VERIFY=true
# Nightly tracker tuning ──────────────────────────────────────────────────────
MOON_TRACK_CROP_PX=480 # pixels — box size around the moon (source coords)
MOON_TRACK_FPS=12 # output mp4 framerate
MOON_TRACK_CRF=24 # output mp4 CRF
MOON_TRACK_RETENTION_DAYS=90 # delete tracker mp4s older than this; 0 = forever
# Dark-sky observation window ─────────────────────────────────────────────────
# The script searches ±24h around the exact phase moment for east frames where:
# 1. The sun is below MOON_DARK_SKY_SUN_ALT_DEG (sky is dark)
# 2. The moon is above MOON_MIN_ALTITUDE_DEG (moon is visible above trees)
#
# Sun altitude thresholds:
# -6 civil twilight — noticeably dark, bright stars visible (default)
# -12 nautical — horizon barely visible
# -18 astronomical — fully dark, no twilight glow
#
# The first east frame in that window with quality >= MOON_MIN_QUALITY is used.
# Its exact timestamp drives the NASA Dial-a-Moon fetch (rounded to nearest hour)
# and the parallactic angle rotation. The caption shows illumination% at that
# moment. If no clear frame is found, the month is skipped entirely.
MOON_DARK_SKY_SUN_ALT_DEG=-6
# Post delay ──────────────────────────────────────────────────────────────────
# How many days after the exact phase to run the post. The post delay gives
# time for the obs-night frames to land on disk before the job runs.
MOON_FULL_POST_DELAY_DAYS=1 # post the morning after the full moon
MOON_QUARTER_POST_DELAY_DAYS=1 # post the morning after each quarter
MOON_QUARTER_MIN_ILLUMINATION=0.46
MOON_QUARTER_MAX_ILLUMINATION=0.54
# Frame-acceptance thresholds — a candidate must beat all three to qualify.
# Lower = more permissive (accept hazier nights / lower moon). If you find
# the script never finds a clear shot, loosen these.
MOON_MIN_QUALITY=0.55 # 0..1 from moon_detect (roundness × halo × isolation)
MOON_MIN_ALTITUDE_DEG=15 # below this the moon is in trees / on the horizon
MOON_FULL_MIN_ILLUMINATION=0.96 # ±4% of 100%: accept 96100% illumination for full moon
# Output frame. Default 1920×1080 to match the sunrise videos.
MOON_OUTPUT_W=1920
MOON_OUTPUT_H=1080
MOON_HEIGHT_PCT=0.92 # moon disk fills this fraction of frame height (full-screen feel)
# NASA SVS Dial-a-Moon ────────────────────────────────────────────────────────
# Hourly pre-rendered moon images, free, public domain, real physics
# (correct phase, libration, crater shadows for any UTC hour).
# https://svs.gsfc.nasa.gov/api/dialamoon/<ISO-DATE>
# We hit it once per phase event (~36 calls/year), cache the result forever.
#MOON_DIALAMOON_API=https://svs.gsfc.nasa.gov/api/dialamoon
#MOON_DIALAMOON_CACHE_DIR="$SCRIPT_DIR/moon-ref/dialamoon"
MOON_DIALAMOON_TARGET_PX=2048 # cached PNG longest side; downsampled on save
MOON_DIALAMOON_TIMEOUT_SEC=30
# ── Mattermost — daily sunrise upload ─────────────────────────────────────────
# mattermost_url, access_token, channel_id go in .env (see bottom of this file).
# ── Notifications — montage / year-end complete + any job failure ─────────────
# Notifications fire when a movement montage or the year video finishes,
# and via systemd OnFailure= if any daily job fails.
# Uncomment and configure one or more methods to enable.
# ntfy — push notifications, zero signup for self-hosted or free cloud tier:
# Self-hosted: https://docs.ntfy.sh/install/
# Cloud: pick any topic name at ntfy.sh (no account needed)
# Set NTFY_URL=https://ntfy.sh/your-topic in .env, then flip the toggle below.
NTFY_ENABLED=true
NTFY_URL="${NTFY_URL:-}"
# Email — requires the 'mail' command (package: mailutils or s-nail).
# Configure outbound SMTP via /etc/ssmtp/ssmtp.conf or msmtp.
# Set EMAIL_TO and EMAIL_FROM in .env, then flip the toggle below.
EMAIL_ENABLED=false
EMAIL_TO="${EMAIL_TO:-}"
EMAIL_FROM="${EMAIL_FROM:-skycam@localhost}"
# Mattermost text post — reuses mattermost_url and access_token above.
# Can be the same daily channel or a separate admin/private channel.
# Set MM_NOTIFY_CHANNEL_ID in .env, then flip the toggle below.
MM_NOTIFY_ENABLED=false
MM_NOTIFY_CHANNEL_ID="${MM_NOTIFY_CHANNEL_ID:-}"
# ── Credentials (.env override) ───────────────────────────────────────────────
# Sensitive values (RTSP URLs with passwords, API tokens) can be kept out of
# this file — and out of git — by placing them in a .env file in the same
# directory. install.sh generates .env.example showing exactly which variables
# to define for your camera list.
#
# To use:
# cp .env.example .env
# $EDITOR .env ← fill in real values
# (never commit .env)
#
_conf_dir="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
[ -f "$_conf_dir/.env" ] && source "$_conf_dir/.env"
unset _conf_dir