The previous east-flavored composite (tight-crop east region + lunar texture overlay) was honest but visually limited: 38-px source moon, no real terminator shadows on quarters, and a faint upscaled-halo background that was less compelling than just a clean lunar render. New flow: - East stays the witness (verifies the moon was visible during the collection window) and supplies the timestamp. - moon_dialamoon.py fetches the NASA SVS Dial-a-Moon render for that exact UTC hour. Free, public-domain, real-physics: correct phase, libration, and crater shadows for any timestamp. - moon_composite.render_phase_closeup() places that render at ~92% of frame height on a 1920x1080 black background -- the long-telephoto look the user asked for. - One API call per phase event (~36/year), cached forever in moon-ref/dialamoon/. Quarter moons now show real 3D crater shadows along the terminator, which the prior algorithmic phase-shadow couldn't simulate from a full-moon reference. Adds MOON_REQUIRE_EAST_VERIFY=true|false toggle so the user can choose "only post when east saw it" (default, current behavior) vs "post every cycle regardless of weather over east." Drops the install-time lunar reference download (no longer needed) and the now-unused per-phase phase-shadow path stays in moon_composite.py for reference / future reuse. https://claude.ai/code/session_015PBVDESC3KLMbq1LpA6qLn
385 lines
16 KiB
Bash
Executable File
385 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
|
|
|
|
# NASA SVS Dial-a-Moon images are fetched on demand by moon_dialamoon.py
|
|
# the first time each phase event is processed (~once per phase per month,
|
|
# ~36 calls/year), cached forever in moon-ref/dialamoon/. No bulk
|
|
# download needed at install time.
|
|
mkdir -p "${MOON_DIALAMOON_CACHE_DIR:-$HERE/moon-ref/dialamoon}"
|
|
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
|