generated from me/template-mit
v2, bash.
This commit is contained in:
1 parent
6ce4a9bbf0
commit
0713442b78
1 file changed
+125
-168
+125
-168
@@ -1,183 +1,140 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env bash
|
||||||
"""Sunset Alarm — alert at a specified offset before sunset.
|
# 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="" # epoch seconds for today's sunset
|
||||||
sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h]
|
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
|
# ── helpers ────────────────────────────────────────────────────────
|
||||||
import json
|
usage() {
|
||||||
import subprocess
|
cat <<'EOF'
|
||||||
import sys
|
Usage: sunset-alarm (-l LAT:LONG | -t HH24:MI) (-m MINUTES) [-h]
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from urllib.error import URLError
|
|
||||||
from urllib.request import urlopen
|
|
||||||
|
|
||||||
|
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):
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
"""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
|
|
||||||
|
|
||||||
|
warn_reconnect() { echo "Network unavailable, retrying in 5 minutes…" >&2; }
|
||||||
|
|
||||||
def parse_hardcoded_time(value):
|
# Query Open-Meteo for today's sunset epoch (local timezone).
|
||||||
"""Parse 'HH:MM' string into a time object and validate."""
|
fetch_sunset_epoch() {
|
||||||
try:
|
local lat="$1" lon="$2"
|
||||||
t = datetime.strptime(value, "%H:%M").time()
|
local json resp_code
|
||||||
except ValueError:
|
# curl –silent + show HTTP code (-w) so we can detect server errors.
|
||||||
raise argparse.ArgumentTypeError(
|
json=$(curl -sf -w '\n%{http_code}' \
|
||||||
f"invalid time format '{value}': expected HH24:MI"
|
"https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=sunset&timezone=auto" \
|
||||||
)
|
--connect-timeout 10 --max-time 30) || return 1
|
||||||
return t
|
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):
|
# Fire a desktop notification (best-effort).
|
||||||
"""Query Open-Meteo for today's sunrise/sunset and return a datetime.
|
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.
|
# ── argument parsing ───────────────────────────────────────────────
|
||||||
"""
|
while [[ $# -gt 0 ]]; do
|
||||||
url = (
|
case "$1" in
|
||||||
f"https://api.open-meteo.com/v1/forecast?"
|
-l)
|
||||||
f"latitude={lat}&longitude={lon}"
|
[[ $# -ge 2 ]] || die "-l requires an argument (LAT:LONG)"
|
||||||
f"¤t=sunrise,sunset"
|
IFS=':' read -r LAT LON <<< "$2"
|
||||||
f"&timezone=auto"
|
shift 2
|
||||||
)
|
;;
|
||||||
with urlopen(url, timeout=30) as resp:
|
-t)
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
[[ $# -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'
|
# ── guardrails ─────────────────────────────────────────────────────
|
||||||
sunset_utc_str = data["current"]["sunset"] # e.g. '2026-10-17T18:56'
|
[[ $# -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")
|
# Need either -l or -t.
|
||||||
return sunset_dt
|
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):
|
# ── Phase 2 — compute alert time and loop ──────────────────────────
|
||||||
"""Send a desktop notification via notify-send."""
|
ALERT_EPOCH=$(( SUNSET - OFFSET_MIN * 60 ))
|
||||||
try:
|
now_epoch=$(date +%s)
|
||||||
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
|
|
||||||
|
|
||||||
|
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():
|
while true; do
|
||||||
parser = argparse.ArgumentParser(
|
now_epoch=$(date +%s)
|
||||||
description="Alert at a specified number of minutes before sunset.",
|
if (( ALERT_EPOCH <= now_epoch && now_epoch < SUNSET )); then
|
||||||
)
|
notify "Sunset Alarm" \
|
||||||
location_group = parser.add_mutually_exclusive_group(required=True)
|
"Triggered (offset: ${OFFSET_MIN:++}$(printf '%d' "$OFFSET_MIN")). Sunset at $(date -d @"$SUNSET" '+%H:%M')."
|
||||||
location_group.add_argument(
|
elif (( now_epoch > SUNSET )); then
|
||||||
"-l",
|
# Day is over — re-fetch for tomorrow (if we have -l).
|
||||||
type=parse_coordinate,
|
if [[ -n "$LAT" && -n "$LON" ]]; then
|
||||||
metavar="LAT:LONG",
|
warn_reconnect=true # just print a quick line, don't sleep
|
||||||
help="Latitude and longitude (e.g. 32.123:-111.111)",
|
while true; do
|
||||||
)
|
fetch_sunset_epoch "$LAT" "$LON" && break
|
||||||
location_group.add_argument(
|
delay=$(( 5 * 60 ))
|
||||||
"-t",
|
warn_reconnect; sleep "$delay" || warn_reconnect
|
||||||
type=parse_hardcoded_time,
|
done
|
||||||
metavar="HH24:MI",
|
else
|
||||||
help="Hardcoded sunset time (e.g. 18:00)",
|
set_sunset_from_time "$HARDCODED_TIME"
|
||||||
)
|
fi
|
||||||
parser.add_argument(
|
ALERT_EPOCH=$(( SUNSET - OFFSET_MIN * 60 ))
|
||||||
"-m",
|
continue
|
||||||
type=int,
|
fi
|
||||||
required=True,
|
sleep 60
|
||||||
metavar="MINUTES",
|
done
|
||||||
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()
|
|
||||||
Reference in new issue
Block a user