Files
sky-cam/install.sh
T
Claude 5fb7ff09a3 Add nightly moon-track timelapse and monthly moon-phase composites
Two new jobs operate on the SUNRISE_CAM, sharing three Python helpers
(moon_phase, moon_detect, moon_composite) and one cached lunar texture:

- moon-track.sh: nightly batch detects the moon in each east frame,
  crops a 480x480 box around it, stitches into an mp4 that holds the
  moon roughly centred while clouds and stars drift past.

- moon-phase-monthly.sh: daily check that runs whichever phase composite
  is due that day. Handles full moon (posts D+3), first quarter (D+2,
  best-effort due to daytime-only geometry from east), and third quarter
  (D+2). Picks the frame closest in time to the exact phase moment that
  meets quality / altitude / illumination thresholds, then composites
  the cached lunar texture into it -- sky/halo/parallactic-angle/timing
  real from east, surface detail borrowed from the reference image.

Honest-by-design: a 38-px white blob from a wide-field IP camera cannot
be enhanced into crater detail by software. The composite makes the
borrowing explicit and constrains everything else (when, where, sky,
orientation) to match what east actually saw.

install.sh now downloads the skyfield ephemeris (de421.bsp) and the
default lunar reference (Wikipedia CC BY-SA full-moon photo) on first
run. Both can be overridden via .env.

https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
2026-04-30 11:40:11 +00:00

399 lines
16 KiB
Bash
Executable File
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.
#!/bin/bash
# install.sh — generate and install sky-cam systemd units from sky-cam.conf.
#
# Usage:
# ./install.sh # installs to ~/.config/systemd/user (no root needed)
# ./install.sh --system # installs to /etc/systemd/system (needs sudo)
#
# Run this once after editing sky-cam.conf, and again whenever sky-cam.conf
# changes (e.g. SCRIPT_DIR moves, cameras added, schedules changed).
# No other file needs editing.
set -euo pipefail
HERE="$(dirname "$(realpath "$0")")"
source "$HERE/sky-cam.conf"
# ── Install target ────────────────────────────────────────────────────────────
SYSTEM_MODE=false
[ "${1:-}" = "--system" ] && SYSTEM_MODE=true
if $SYSTEM_MODE; then
UNIT_DIR="/etc/systemd/system"
SC="sudo systemctl"
else
UNIT_DIR="$HOME/.config/systemd/user"
SC="systemctl --user"
fi
mkdir -p "$UNIT_DIR"
echo "Installing systemd units to $UNIT_DIR ..."
# ── Ensure all scripts are executable ────────────────────────────────────────
chmod +x "$HERE"/*.sh "$HERE"/*.py 2>/dev/null || true
# ── Pre-create data directories for all cameras ───────────────────────────────
echo "Creating data directories..."
for cam in "${CAMERAS[@]}"; do
mkdir -p "$BASE_DIR/$cam"
mkdir -p "$MOVIES_DIR/$cam"
mkdir -p "$AUDIO_DIR/$cam"
echo " $BASE_DIR/$cam"
echo " $MOVIES_DIR/$cam"
echo " $AUDIO_DIR/$cam"
done
mkdir -p "$BASE_DIR/$SUNRISE_CAM"
mkdir -p "$AUDIO_DIR/sunrise/$SUNRISE_CAM"
echo ""
# ── Helper: write a oneshot service unit ─────────────────────────────────────
write_service() {
local unit="$1" description="$2" script="$3" extra="${4:-}"
cat > "$UNIT_DIR/${unit}.service" <<EOF
[Unit]
Description=Sky-Cam — $description
${extra}OnFailure=sky-cam-notify-failure@%n.service
[Service]
Type=oneshot
ExecStart=$SCRIPT_DIR/$script
StandardOutput=journal
StandardError=journal
EOF
echo " wrote ${unit}.service"
}
# ── Helper: write a timer unit ───────────────────────────────────────────────
write_timer() {
local unit="$1" description="$2" calendar="$3"
cat > "$UNIT_DIR/${unit}.timer" <<EOF
[Unit]
Description=Sky-Cam — $description
[Timer]
OnCalendar=*-*-* $calendar
Persistent=true
[Install]
WantedBy=timers.target
EOF
echo " wrote ${unit}.timer"
}
# ── Failure notification template ────────────────────────────────────────────
cat > "$UNIT_DIR/sky-cam-notify-failure@.service" <<EOF
[Unit]
Description=Sky-Cam notify on failure for %i
[Service]
Type=oneshot
ExecStart=$SCRIPT_DIR/notify.sh "sky-cam job failed: %i" "systemd unit %i failed — check: journalctl -u %i"
EOF
echo " wrote sky-cam-notify-failure@.service"
# ── Sunrise video — SUNRISE_CAM only ─────────────────────────────────────────
# OnSuccess= triggers the upload service only when video creation succeeds.
cat > "$UNIT_DIR/sky-cam-sunrise.service" <<EOF
[Unit]
Description=Sky-Cam — $SUNRISE_CAM: daily sunrise video
After=network-online.target
Wants=network-online.target
OnFailure=sky-cam-notify-failure@%n.service
OnSuccess=sky-cam-sunrise-upload.service
[Service]
Type=oneshot
ExecStart=$SCRIPT_DIR/daily_sunrise_video.sh $SUNRISE_CAM
StandardOutput=journal
StandardError=journal
EOF
echo " wrote sky-cam-sunrise.service"
cat > "$UNIT_DIR/sky-cam-sunrise-upload.service" <<EOF
[Unit]
Description=Sky-Cam — $SUNRISE_CAM: upload daily sunrise to Mattermost
After=network-online.target
Wants=network-online.target
OnFailure=sky-cam-notify-failure@%n.service
[Service]
Type=oneshot
ExecStart=$SCRIPT_DIR/sunrise2mm.py
StandardOutput=journal
StandardError=journal
EOF
echo " wrote sky-cam-sunrise-upload.service"
write_timer "sky-cam-sunrise" "$SUNRISE_CAM: daily sunrise video" "$SCHEDULE_SUNRISE"
timers=(sky-cam-sunrise)
# ── Per-camera continuous capture services ────────────────────────────────────
# capture.sh replaces MotionEye / any NVR for periodic JPEG capture.
# Generated when CAM_RTSP_<cam> is set; Type=simple (long-running, not oneshot).
capture_services=()
for cam in "${CAMERAS[@]}"; do
rtsp_var="CAM_RTSP_${cam}"
if [ -n "${!rtsp_var:-}" ]; then
cat > "$UNIT_DIR/sky-cam-capture-${cam}.service" <<EOF
[Unit]
Description=Sky-Cam — ${cam}: continuous RTSP frame capture
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SCRIPT_DIR/capture.sh ${cam}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
EOF
echo " wrote sky-cam-capture-${cam}.service"
capture_services+=("sky-cam-capture-${cam}")
cat > "$UNIT_DIR/sky-cam-watchdog-${cam}.service" <<EOF
[Unit]
Description=Sky-Cam — ${cam}: capture watchdog (stall detection)
After=sky-cam-capture-${cam}.service
Wants=sky-cam-capture-${cam}.service
[Service]
Type=simple
ExecStart=$SCRIPT_DIR/capture-watchdog.sh ${cam}
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
EOF
echo " wrote sky-cam-watchdog-${cam}.service"
capture_services+=("sky-cam-watchdog-${cam}")
else
echo " WARNING: CAM_RTSP_${cam} not set — skipping capture service for ${cam}"
echo " (set CAM_RTSP_${cam}=rtsp://... in sky-cam.conf to enable)"
fi
done
# ── Sunrise audio capture ─────────────────────────────────────────────────────
# Uses the same schedule as the sunrise video — both start early and wait
# internally for the right moment, so they don't interfere.
if [ "${AUDIO_ENABLED:-false}" = "true" ]; then
write_service "sky-cam-audio-capture" \
"$SUNRISE_CAM: sunrise audio capture" \
"sunrise-audio-capture.sh"
write_timer "sky-cam-audio-capture" \
"$SUNRISE_CAM: sunrise audio capture" \
"$SCHEDULE_SUNRISE"
timers+=("sky-cam-audio-capture")
fi
# ── Ambient audio recording services ─────────────────────────────────────────
# One long-running service per camera in AMBIENT_CAMS.
# Enabled alongside the capture services so install.sh restarts them on update.
if [ "${AMBIENT_ENABLED:-false}" = "true" ]; then
for cam in "${AMBIENT_CAMS[@]}"; do
rtsp_var="CAM_RTSP_${cam}"
if [ -n "${!rtsp_var:-}" ]; then
cat > "$UNIT_DIR/sky-cam-ambient-${cam}.service" <<EOF
[Unit]
Description=Sky-Cam — ${cam}: ambient audio recorder
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$SCRIPT_DIR/ambient-record.sh ${cam}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
EOF
echo " wrote sky-cam-ambient-${cam}.service"
capture_services+=("sky-cam-ambient-${cam}")
else
echo " WARNING: CAM_RTSP_${cam} not set — skipping ambient service for ${cam}"
fi
done
fi
# ── Moon jobs: nightly tracker + monthly full-moon composite ────────────────
# Both jobs target the SUNRISE_CAM (the east-facing camera). They share three
# Python helpers (moon_phase.py, moon_detect.py, moon_composite.py) and one
# downloaded asset (the lunar reference texture).
if [ "${MOON_TRACK_ENABLED:-true}" = "true" ] || [ "${MOON_FULL_ENABLED:-true}" = "true" ]; then
# Pre-download skyfield ephemeris so first run doesn't hit the network at
# an inopportune moment. Stored next to the scripts.
if [ ! -f "$HERE/de421.bsp" ]; then
echo "Downloading skyfield ephemeris (de421.bsp, ~17 MB)..."
if curl -fsSL -o "$HERE/de421.bsp" \
"https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp"; then
echo " ok"
else
echo " WARNING: ephemeris download failed — moon jobs will retry on first run"
rm -f "$HERE/de421.bsp"
fi
fi
# Lunar reference texture for moon_phase_monthly.py. Default URL is the
# Wikipedia "FullMoon2010" by Gregory H. Revera (CC BY-SA 3.0), 3500×3500.
# Override MOON_REFERENCE_URL in .env to use a different source — any
# high-res photo of a full moon on a black background works.
ref_dir="${MOON_REFERENCE_DIR:-$HERE/moon-ref}"
ref_path="${MOON_REFERENCE_PATH:-$ref_dir/full-moon.jpg}"
ref_url="${MOON_REFERENCE_URL:-https://upload.wikimedia.org/wikipedia/commons/e/e1/FullMoon2010.jpg}"
if [ ! -f "$ref_path" ]; then
mkdir -p "$(dirname "$ref_path")"
echo "Downloading lunar reference texture..."
if curl -fsSL -o "$ref_path" "$ref_url"; then
echo " ok → $ref_path"
else
echo " WARNING: lunar reference download failed"
echo " Drop a high-res full-moon JPEG at $ref_path manually,"
echo " or set MOON_REFERENCE_URL in .env and re-run install.sh."
rm -f "$ref_path"
fi
fi
fi
# Nightly moon-track job — runs on the SUNRISE_CAM only.
if [ "${MOON_TRACK_ENABLED:-true}" = "true" ]; then
write_service "sky-cam-moon-track" \
"$SUNRISE_CAM: nightly moon tracking timelapse" \
"moon-track.sh $SUNRISE_CAM"
write_timer "sky-cam-moon-track" \
"$SUNRISE_CAM: nightly moon tracking timelapse" \
"${SCHEDULE_MOON_TRACK:-02:30:00}"
timers+=("sky-cam-moon-track")
fi
# Moon-phase composite job — runs daily; the Python helper internally checks
# full / first-quarter / third-quarter and only acts when today is the
# matching post-day for one of them. One timer covers all three phases.
if [ "${MOON_FULL_ENABLED:-true}" = "true" ] \
|| [ "${MOON_FIRST_QUARTER_ENABLED:-true}" = "true" ] \
|| [ "${MOON_THIRD_QUARTER_ENABLED:-true}" = "true" ]; then
write_service "sky-cam-moon-phase" \
"$SUNRISE_CAM: monthly moon-phase composite (full + quarters)" \
"moon-phase-monthly.sh" \
$'After=network-online.target\nWants=network-online.target\n'
write_timer "sky-cam-moon-phase" \
"$SUNRISE_CAM: monthly moon-phase composite" \
"${SCHEDULE_MOON_PHASE:-09:30:00}"
timers+=("sky-cam-moon-phase")
fi
# ── Per-camera Four Seasons jobs ─────────────────────────────────────────────
# Reads CAMERAS and SCHEDULE_SEASONS_<cam> from sky-cam.conf.
# Script receives the camera name as $1 so it knows which camera to process.
for cam in "${CAMERAS[@]}"; do
sched_seasons_var="SCHEDULE_SEASONS_${cam}"
if [ -n "${!sched_seasons_var:-}" ]; then
write_service "sky-cam-seasons-${cam}" \
"${cam}: Four Seasons daily clip" \
"4-seasons.sh ${cam}" \
$'After=network-online.target\nWants=network-online.target\n'
write_timer "sky-cam-seasons-${cam}" "${cam}: Four Seasons daily clip" \
"${!sched_seasons_var}"
timers+=("sky-cam-seasons-${cam}")
else
echo " WARNING: SCHEDULE_SEASONS_${cam} not set — skipping seasons timer for ${cam}"
fi
done
# ── Generate .env.example ────────────────────────────────────────────────────
env_example="$HERE/.env.example"
{
echo "# sky-cam runtime config — copy to .env and fill in real values."
echo "# .env is gitignored and never checked in."
echo "# Everything here overrides the defaults in sky-cam.conf."
echo ""
echo "# ── Location ────────────────────────────────────────────────────────────────"
echo "# Used by sunrise.py to calculate today's sunrise time."
echo "# Right-click your location in Google Maps to get lat/long."
echo "# TIMEZONE: use the TZ identifier column from"
echo "# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
echo "LATITUDE=0.0000"
echo "LONGITUDE=0.0000"
echo "TIMEZONE=America/New_York"
echo ""
echo "# ── Storage paths ───────────────────────────────────────────────────────────"
echo "# Override any of these if your images, videos, or music live on a"
echo "# different drive. Leave commented to use the defaults next to the scripts."
echo "#BASE_DIR=/path/to/images # default: ~/sky-cam/data"
echo "#MOVIES_DIR=/path/to/videos # default: \$BASE_DIR/movies"
echo "#AUDIO_DIR=/path/to/audio # default: \$BASE_DIR/audio"
echo "#MUSIC_DIR=/path/to/music/4Seasons # default: ~/sky-cam/music"
echo ""
echo "# ── RTSP stream URLs ────────────────────────────────────────────────────────"
echo "# One line per camera. Use single quotes to prevent shell interpretation."
echo "# URL-encode special characters in the password:"
echo "# !=%21 @=%40 #=%23 \$=%24 %=%25 ^=%5E &=%26 *=%2A :=%3A /=%2F"
echo "# Example: password my@p\$ss:word! → my%40p%24ss%3Aword%21"
echo ""
for cam in "${CAMERAS[@]}"; do
echo "CAM_RTSP_${cam}='rtsp://admin:PASSWORD@192.168.1.XXX:554/stream1'"
done
echo ""
echo "# ── Mattermost upload ───────────────────────────────────────────────────────"
echo "mattermost_url=https://your-mattermost-server.example.com"
echo "access_token=your-access-token-here"
echo "channel_id=your-daily-upload-channel-id-here"
echo ""
echo "# ── Notifications ───────────────────────────────────────────────────────────"
echo "# Set the private values here, then flip the matching *_ENABLED toggle"
echo "# to true in sky-cam.conf."
echo "#NTFY_URL=https://ntfy.sh/your-topic"
echo "#EMAIL_TO=you@example.com"
echo "#EMAIL_FROM=skycam@localhost"
echo "#MM_NOTIFY_CHANNEL_ID=your-notify-channel-id-here"
} > "$env_example"
echo " wrote .env.example (cp .env.example .env then fill in real values)"
# ── User-session prerequisites (non-system installs only) ────────────────────
if ! $SYSTEM_MODE; then
# Make systemctl --user reachable when run outside a login session
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
# Start user services at boot even without an interactive login
loginctl enable-linger "$USER" \
&& echo " loginctl enable-linger: ok" \
|| echo " WARNING: loginctl enable-linger failed (may need to run manually)"
fi
# ── Reload and enable ─────────────────────────────────────────────────────────
echo ""
$SC daemon-reload
for svc in "${capture_services[@]}"; do
if $SC enable "${svc}.service" && $SC restart "${svc}.service"; then
echo " enabled + started ${svc}.service"
else
echo " WARNING: could not enable/start ${svc}.service"
fi
done
for timer in "${timers[@]}"; do
$SC enable --now "${timer}.timer" \
&& echo " enabled + started ${timer}.timer" \
|| echo " WARNING: could not enable ${timer}.timer"
done
echo ""
echo "Done. Check status with:"
if $SYSTEM_MODE; then
echo " sudo systemctl list-timers 'sky-cam-*'"
echo " sudo journalctl -u sky-cam-seasons-${CAMERAS[0]}.service -f"
else
echo " systemctl --user list-timers 'sky-cam-*'"
echo " journalctl --user -u sky-cam-seasons-${CAMERAS[0]}.service -f"
fi