Files
home_assistant/sunrise_alarm.py
tomatocream 3a40741096 Add README, sunrise alarm, CCT (white/warm) support and configs
- README: WLED pull/push usage, sunrise alarm, white vs warm (CCT) section
- wled_push: --cct 0-255 for segment 0 color temperature
- wled_config: white.json, warm.json, sunrise presets; updated state/full

Made-with: Cursor
2026-03-09 00:50:35 +08:00

174 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""
Poll local time and trigger a WLED sunrise once per day.
Example:
python sunrise_alarm.py
python sunrise_alarm.py --alarm 08:00 --duration 30 --poll-seconds 20
"""
import argparse
import json
import sys
import time
from copy import deepcopy
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict
from wled_push import DEFAULT_HOST, push
ROOT = Path(__file__).resolve().parent
DEFAULT_CONFIG = ROOT / "wled_config" / "sunrise_8am.json"
DEFAULT_STATE = ROOT / ".sunrise_alarm_state.json"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Trigger a WLED sunrise once per day")
parser.add_argument("--host", "-H", default=DEFAULT_HOST, help=f"WLED host (default: {DEFAULT_HOST})")
parser.add_argument("--config", "-c", default=str(DEFAULT_CONFIG), help="Sunrise payload JSON")
parser.add_argument("--alarm", "-a", default="08:00", help="Alarm time in local HH:MM (default: 08:00)")
parser.add_argument(
"--duration",
"-d",
type=int,
default=30,
help="Sunrise duration in minutes; script starts this many minutes before the alarm",
)
parser.add_argument(
"--poll-seconds",
"-p",
type=int,
default=20,
help="How often to check the clock (default: 20)",
)
parser.add_argument(
"--window-seconds",
"-w",
type=int,
default=60,
help="Trigger window size in seconds to tolerate clock drift (default: 60)",
)
parser.add_argument(
"--state-file",
default=str(DEFAULT_STATE),
help="File used to remember the last day that was triggered",
)
parser.add_argument(
"--run-now",
action="store_true",
help="Trigger immediately once and exit",
)
return parser.parse_args()
def parse_alarm_time(value: str) -> tuple[int, int]:
try:
hour_str, minute_str = value.split(":", 1)
hour = int(hour_str)
minute = int(minute_str)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Invalid alarm time '{value}', expected HH:MM") from exc
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise argparse.ArgumentTypeError(f"Invalid alarm time '{value}', expected HH:MM")
return hour, minute
def load_payload(path: Path, duration_minutes: int) -> Dict[str, Any]:
with path.open() as handle:
payload = json.load(handle)
payload = deepcopy(payload)
payload.setdefault("nl", {})
payload["nl"]["on"] = True
payload["nl"]["dur"] = duration_minutes
payload["nl"].setdefault("mode", 3)
payload["nl"].setdefault("tbri", 255)
payload["on"] = True
payload.setdefault("bri", 1)
return payload
def load_last_triggered(path: Path) -> str:
if not path.exists():
return ""
try:
with path.open() as handle:
data = json.load(handle)
except (json.JSONDecodeError, OSError):
return ""
return str(data.get("last_triggered_date", ""))
def save_last_triggered(path: Path, day: str) -> None:
path.write_text(json.dumps({"last_triggered_date": day}, indent=2) + "\n")
def compute_trigger_time(now: datetime, alarm_hour: int, alarm_minute: int, duration_minutes: int) -> datetime:
alarm_at = now.replace(hour=alarm_hour, minute=alarm_minute, second=0, microsecond=0)
return alarm_at - timedelta(minutes=duration_minutes)
def should_trigger(now: datetime, trigger_at: datetime, window_seconds: int, last_triggered_date: str) -> bool:
if last_triggered_date == now.date().isoformat():
return False
return trigger_at <= now < trigger_at + timedelta(seconds=window_seconds)
def trigger(host: str, payload: Dict[str, Any], state_file: Path) -> None:
print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] triggering sunrise on {host}")
push(host, payload, return_state=False)
save_last_triggered(state_file, datetime.now().date().isoformat())
def main() -> int:
args = parse_args()
alarm_hour, alarm_minute = parse_alarm_time(args.alarm)
config_path = Path(args.config).expanduser().resolve()
state_file = Path(args.state_file).expanduser().resolve()
if not config_path.exists():
print(f"Config file not found: {config_path}", file=sys.stderr)
return 1
if args.duration <= 0:
print("--duration must be greater than 0", file=sys.stderr)
return 1
if args.poll_seconds <= 0:
print("--poll-seconds must be greater than 0", file=sys.stderr)
return 1
if args.window_seconds <= 0:
print("--window-seconds must be greater than 0", file=sys.stderr)
return 1
payload = load_payload(config_path, args.duration)
if args.run_now:
trigger(args.host, payload, state_file)
return 0
print(
f"Polling for sunrise: alarm={args.alarm}, duration={args.duration}m, "
f"start={args.alarm} minus {args.duration}m, host={args.host}"
)
while True:
now = datetime.now().astimezone()
trigger_at = compute_trigger_time(now, alarm_hour, alarm_minute, args.duration)
last_triggered_date = load_last_triggered(state_file)
if should_trigger(now, trigger_at, args.window_seconds, last_triggered_date):
try:
payload = load_payload(config_path, args.duration)
trigger(args.host, payload, state_file)
except Exception as exc: # noqa: BLE001
print(f"[{now.isoformat(timespec='seconds')}] trigger failed: {exc}", file=sys.stderr)
time.sleep(args.poll_seconds)
if __name__ == "__main__":
raise SystemExit(main())