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
399 lines
16 KiB
Bash
Executable File
399 lines
16 KiB
Bash
Executable File
#!/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
|