#!/usr/bin/env python3 """Sunset Alarm — alert at a specified offset before sunset. Usage: sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h] """ 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 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 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 def fetch_sunset(lat, lon): """Query Open-Meteo for today's sunrise/sunset and return a datetime. 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")) sunrise_utc_str = data["current"]["sunrise"] # e.g. '2026-10-17T04:23' sunset_utc_str = data["current"]["sunset"] # e.g. '2026-10-17T18:56' sunset_dt = datetime.strptime(sunset_utc_str, "%Y-%m-%dT%H:%M") return sunset_dt 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 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()