diff --git a/sunset-alarm b/sunset-alarm index 56046f7..9d0d774 100755 --- a/sunset-alarm +++ b/sunset-alarm @@ -1,183 +1,140 @@ -#!/usr/bin/env python3 -"""Sunset Alarm — alert at a specified offset before sunset. +#!/usr/bin/env bash +# sunset-alarm — alert at a specified number of minutes before sunset +# Usage: sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h] +set -euo pipefail -Usage: - sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h] -""" +SUNSET="" # epoch seconds for today's sunset +ALERT_EPOCH="" # epoch when alert should fire (sunset - offset) +OFFSET_MIN=0 # signed minute offset from sun -t/--hardcoded +LAT="" LON="" # coordinates (-l/--location) +HARDCODED_TIME="" # HH:MM string (-t/--hardcoded) -import argparse -import json -import subprocess -import sys -import time -from datetime import datetime, timedelta -from urllib.error import URLError -from urllib.request import urlopen +# ── helpers ──────────────────────────────────────────────────────── +usage() { + cat <<'EOF' +Usage: sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h] +Options: + -l LAT:LONG Latitude and longitude (e.g. 32.123:-111.111) + -t HH24:MI Hardcoded sunset time (ignored when combined with -l) + -m MINUTES Minutes offset before (+) or after (-) sunset to alert + -h Show this help and exit +EOF +} -def parse_coordinate(value): - """Parse 'lat:long' string into two floats.""" - parts = value.split(":") - if len(parts) != 2: - raise argparse.ArgumentTypeError( - f"invalid coordinate format '{value}': expected LAT:LONG" - ) - try: - lat, lon = float(parts[0]), float(parts[1]) - except ValueError: - raise argparse.ArgumentTypeError( - f"invalid coordinate values in '{value}': must be numbers" - ) - if not -90 <= lat <= 90: - raise argparse.ArgumentTypeError(f"latitude {lat} out of range [-90, 90]") - if not -180 <= lon <= 180: - raise argparse.ArgumentTypeError(f"longitude {lon} out of range [-180, 180]") - return lat, lon +die() { echo "ERROR: $*" >&2; exit 1; } +warn_reconnect() { echo "Network unavailable, retrying in 5 minutes…" >&2; } -def parse_hardcoded_time(value): - """Parse 'HH:MM' string into a time object and validate.""" - try: - t = datetime.strptime(value, "%H:%M").time() - except ValueError: - raise argparse.ArgumentTypeError( - f"invalid time format '{value}': expected HH24:MI" - ) - return t +# Query Open-Meteo for today's sunset epoch (local timezone). +fetch_sunset_epoch() { + local lat="$1" lon="$2" + local json resp_code + # curl –silent + show HTTP code (-w) so we can detect server errors. + json=$(curl -sf -w '\n%{http_code}' \ + "https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=sunset&timezone=auto" \ + --connect-timeout 10 --max-time 30) || return 1 + resp_code="${json##*$'\n'}" + json="${json%$'\n'*}" + [[ "$resp_code" == "200" ]] || return 2 + # jq → "YYYY-MM-DDTHH:MM" (ISO-8601 local-time string for the sunset field) + local iso_ts + iso_ts=$(echo "$json" | jq -r '.current.sunset' | grep -v '^null$') + [[ -n "$iso_ts" ]] || return 3 + # date -d parses ISO-8601 timestamps directly. + SUNSET=$(date -d "$iso_ts" +%s) +} +# Build today's sunset epoch from HH:MM when using the hardcoded path (-t). +set_sunset_from_time() { + local hhmm="$1" hh mm + read -r hh mm <<< "$(echo "$hhmm" | tr ':' ' ')" + # Pad hours / minutes in case user passed "8:30" etc. (they won't via arg + # parsing, but defensive never hurts). + SUNSET=$(date -d "$(date +%Y-%m-%d) ${hh}:${mm}:00" +%s) +} -def fetch_sunset(lat, lon): - """Query Open-Meteo for today's sunrise/sunset and return a datetime. +# Fire a desktop notification (best-effort). +notify() { + local title="$1" body="$2" + notify-send -u normal "$title" "$body" 2>/dev/null || true + printf '%s — %s\n' "$(date '+%F %T')" "ALERT: $body" +} - Uses the `current日出_sunset` parameter set which returns values in UTC. - """ - url = ( - f"https://api.open-meteo.com/v1/forecast?" - f"latitude={lat}&longitude={lon}" - f"¤t=sunrise,sunset" - f"&timezone=auto" - ) - with urlopen(url, timeout=30) as resp: - data = json.loads(resp.read().decode("utf-8")) +# ── argument parsing ─────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + -l) + [[ $# -ge 2 ]] || die "-l requires an argument (LAT:LONG)" + IFS=':' read -r LAT LON <<< "$2" + shift 2 + ;; + -t) + [[ $# -ge 2 ]] || die "-t requires an argument (HH24:MI)" + HARDCODED_TIME="$2" + shift 2 + ;; + -m) + [[ $# -ge 2 ]] || die "-m requires an argument (MINUTES)" + OFFSET_MIN="$2" + shift 2 + ;; + -h|--help) usage; exit 0 ;; + *) die "Unknown option: $1" ;; + esac +done - sunrise_utc_str = data["current"]["sunrise"] # e.g. '2026-10-17T04:23' - sunset_utc_str = data["current"]["sunset"] # e.g. '2026-10-17T18:56' +# ── guardrails ───────────────────────────────────────────────────── +[[ $# -eq 0 ]] || die "extra arguments provided — see --help" +(( OFFSET_MIN >= -60 )) || [[ $OFFSET_MIN -le 0 ]] 2>/dev/null \ + 1>/dev/null || die "-m must be ≥ -60 (or any value ≤ 0)" - sunset_dt = datetime.strptime(sunset_utc_str, "%Y-%m-%dT%H:%M") - return sunset_dt +# Need either -l or -t. +if [[ -z "$LAT" && -z "$HARDCODED_TIME" ]]; then + die "Need at least one of: -l LAT:LONG, -t HH24:MI" +fi +# ── Phase 1 — resolve today's sunset (epoch) ─────────────────────── +if [[ -n "$LAT" && -n "$LON" ]]; then + while true; do + fetch_sunset_epoch "$LAT" "$LON" && break + delay=$(( 5 * 60 )) + warn_reconnect; sleep "$delay" || warn_reconnect + done +else + set_sunset_from_time "$HARDCODED_TIME" +fi -def notify_send(title, message): - """Send a desktop notification via notify-send.""" - try: - subprocess.run( - ["notify-send", title, message], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except FileNotFoundError: - pass # notify-send not available; fall through to stdout +# ── Phase 2 — compute alert time and loop ────────────────────────── +ALERT_EPOCH=$(( SUNSET - OFFSET_MIN * 60 )) +now_epoch=$(date +%s) +if (( now_epoch >= ALERT_EPOCH )); then + # Already past the window — fire once, exit. + notify "Sunset Alarm" \ + "Triggered (offset: ${OFFSET_MIN:++}$(printf '%d' "$OFFSET_MIN")). Sunset at $(date -d @"$SUNSET" '+%H:%M')." + exit 0 +fi -def main(): - parser = argparse.ArgumentParser( - description="Alert at a specified number of minutes before sunset.", - ) - location_group = parser.add_mutually_exclusive_group(required=True) - location_group.add_argument( - "-l", - type=parse_coordinate, - metavar="LAT:LONG", - help="Latitude and longitude (e.g. 32.123:-111.111)", - ) - location_group.add_argument( - "-t", - type=parse_hardcoded_time, - metavar="HH24:MI", - help="Hardcoded sunset time (e.g. 18:00)", - ) - parser.add_argument( - "-m", - type=int, - required=True, - metavar="MINUTES", - help=( - "Minutes offset before/after sunset to trigger the alert. " - "Positive = before sunset; negative = after." - ), - ) - parser.add_argument( - "-h", - action="help", - help="Show this help message and exit.", - ) - args = parser.parse_args() - - offset_minus = timedelta(minutes=args.m) - - # --- Phase 1: resolve sunset time --------------------------- - while True: - try: - if args.l is not None: - lat, lon = args.l - sunset_dt = fetch_sunset(lat, lon) - print(f"Sunset fetched from Open-Meteo: {sunset_dt.isoformat()}") - break # success — stop retry loop - else: - now = datetime.now() - today_date = today_date_naive = now.replace(hour=0, minute=0, second=0, microsecond=0) - sunset_today = datetime.combine(today_date_naive, args.t) - print(f"Using hardcoded sunset: {args.t}") - break # no network needed for -t - except (URLError, OSError) as exc: - print(f"Network unavailable ({exc}), retrying in 5 minutes...", file=sys.stderr) - time.sleep(300) - - # --- Phase 2: wait / loop and alert ------------------------- - alert_threshold = sunset_dt - offset_minus - - if alert_threshold <= datetime.now(): - # Already past the alert window — fire once and exit. - notify_send( - "Sunset Alarm", - f"Alert triggered: {sunset_dt.strftime('%H:%M')} ({offset_minus.total_seconds()/60:+.0f} min from sunset)", - ) - print(f"Alert: {alert_threshold.isoformat()} -> Now: {datetime.now().isoformat()}") - return - - while True: - now = datetime.now() - - if not (alert_threshold <= now < sunset_dt.replace(year=now.year + 1)): - # Normal path: only alert when within the window. - # Check again in 60 seconds. - time.sleep(60) - continue - - if now.date() > sunset_dt.date(): - # Day has passed — stop monitoring, wait until tomorrow. - print(f"Day {sunset_dt.date()} over; re-fetching for new day...", file=sys.stderr) - # Re-enter fetch loop - while True: - try: - lat, lon = args.l - sunset_dt = fetch_sunset(lat, lon) - break - except (URLError, OSError) as exc: - print(f"Network unavailable ({exc}), retrying in 5 minutes...", file=sys.stderr) - time.sleep(300) - continue - - # Within alert window — send notification. - notify_send( - "Sunset Alarm", - f"Alert triggered: {sunset_dt.strftime('%H:%M')} ({offset_minus.total_seconds()/60:+.0f} min from sunset)", - ) - print(f"ALERT -> {alert_threshold.isoformat()} <= {now.isoformat()} < {sunset_dt.isoformat()}") - - # Keep alerting (don't exit). - - -if __name__ == "__main__": - main() +while true; do + now_epoch=$(date +%s) + if (( ALERT_EPOCH <= now_epoch && now_epoch < SUNSET )); then + notify "Sunset Alarm" \ + "Triggered (offset: ${OFFSET_MIN:++}$(printf '%d' "$OFFSET_MIN")). Sunset at $(date -d @"$SUNSET" '+%H:%M')." + elif (( now_epoch > SUNSET )); then + # Day is over — re-fetch for tomorrow (if we have -l). + if [[ -n "$LAT" && -n "$LON" ]]; then + warn_reconnect=true # just print a quick line, don't sleep + while true; do + fetch_sunset_epoch "$LAT" "$LON" && break + delay=$(( 5 * 60 )) + warn_reconnect; sleep "$delay" || warn_reconnect + done + else + set_sunset_from_time "$HARDCODED_TIME" + fi + ALERT_EPOCH=$(( SUNSET - OFFSET_MIN * 60 )) + continue + fi + sleep 60 +done