From 6ce4a9bbf0f46bad298f37894e317d3bc721af26 Mon Sep 17 00:00:00 2001 From: Hyperling Date: Sat, 17 Oct 2026 13:32:50 -0700 Subject: [PATCH] v1 --- sunset-alarm | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 sunset-alarm diff --git a/sunset-alarm b/sunset-alarm new file mode 100755 index 0000000..56046f7 --- /dev/null +++ b/sunset-alarm @@ -0,0 +1,183 @@ +#!/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()