diff --git a/README.md b/README.md new file mode 100644 index 0000000..b74f21a --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Home Assistant + +Scripts and config for managing WLED at `192.168.240.30`. + +## WLED scripts + +Python 3, no extra deps (stdlib only). + +### Pull (read state/config from WLED) + +```bash +# Full snapshot (state + info + effects + palettes) +python wled_pull.py + +# Just state, info, or config +python wled_pull.py state +python wled_pull.py info +python wled_pull.py cfg + +# Save to file +python wled_pull.py --save wled_config/backup.json +python wled_pull.py full -o wled_config/full.json +``` + +### Push (send commands to WLED) + +```bash +python wled_push.py --on +python wled_push.py --off +python wled_push.py --toggle +python wled_push.py --bri 128 +python wled_push.py --preset 1 +python wled_push.py --file wled_config/state.json +python wled_push.py --file wled_config/sunrise_8am.json +``` + +Use `--host` to override the default `192.168.240.30`. + +### White vs warm light (CCT) + +Your WLED device (**WLED-Gledopto** at `192.168.240.30`) supports **CCT** (color temperature). In the API, segment `cct` uses a relative scale: + +- **`cct: 0`** → warmest (warm white, ~2700 K) +- **`cct: 255`** → coolest (cool white, ~6500 K) + +**Current state** (from your last pull): light **on**, brightness **254**, segment **0** with **Solid** effect (`fx: 0`), **CCT 0** (warm), primary color dim gray `[56,56,56,56]`. + +**Set cool/white light:** + +```bash +python wled_push.py --on --bri 255 --cct 255 +# or from a JSON file: +python wled_push.py --file wled_config/white.json +``` + +**Set warm light:** + +```bash +python wled_push.py --on --bri 255 --cct 0 +# or: +python wled_push.py --file wled_config/warm.json +``` + +To only change color temperature and keep current on/off and brightness, use only `--cct`: + +```bash +python wled_push.py --cct 255 # switch to cool white +python wled_push.py --cct 0 # switch to warm +``` + +### Config backup + +`wled_config/` holds JSON pulled from the device: `full.json`, `state.json`, `info.json`. Use `wled_push.py --file wled_config/state.json` to restore state. + +## Sunrise alarm + +`wled_config/sunrise_8am.json` starts a 30-minute sunrise that finishes at `08:00`. + +```bash +# Test the sunrise payload immediately +python wled_push.py --file wled_config/sunrise_8am.json + +# Poll the local clock and trigger the sunrise daily +python sunrise_alarm.py + +# Custom alarm time and duration +python sunrise_alarm.py --alarm 08:00 --duration 30 +``` diff --git a/sunrise_alarm.py b/sunrise_alarm.py new file mode 100644 index 0000000..7698ecb --- /dev/null +++ b/sunrise_alarm.py @@ -0,0 +1,173 @@ +#!/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()) diff --git a/wled_config/full.json b/wled_config/full.json index ea15fe3..27fda48 100644 --- a/wled_config/full.json +++ b/wled_config/full.json @@ -1,8 +1,8 @@ { "state": { "on": true, - "bri": 254, - "transition": 7, + "bri": 255, + "transition": 0, "ps": -1, "pl": -1, "ledmap": 0, @@ -11,13 +11,13 @@ }, "nl": { "on": false, - "dur": 60, - "mode": 1, - "tbri": 0, + "dur": 30, + "mode": 3, + "tbri": 255, "rem": -1 }, "udpn": { - "send": false, + "send": true, "recv": true, "sgrp": 1, "rgrp": 1 @@ -40,10 +40,10 @@ "set": 0, "col": [ [ - 56, - 56, - 56, - 56 + 112, + 112, + 112, + 112 ], [ 0, @@ -104,7 +104,7 @@ "liveseg": -1, "lm": "", "lip": "", - "ws": 1, + "ws": 3, "fxcount": 187, "palcount": 71, "cpalcount": 0, @@ -115,7 +115,7 @@ ], "wifi": { "bssid": "A4:A9:30:F9:4D:96", - "rssi": -49, + "rssi": -42, "signal": 100, "channel": 1, "ap": false @@ -131,9 +131,9 @@ "clock": 240, "flash": 4, "lwip": 0, - "freeheap": 169496, - "uptime": 10830, - "time": "2026-3-7, 16:52:59", + "freeheap": 166208, + "uptime": 97122, + "time": "2026-3-8, 16:50:25", "u": { "AudioReactive": [ "" @@ -143,13 +143,13 @@ ], "Audio Source": [ "I2S digital", - " - peak 99%" + " - quiet" ], "Sound Processing": [ "running" ], "AGC Gain": [ - 8.46, + 5.36, "x" ], "UDP Sound Sync": [ diff --git a/wled_config/state.json b/wled_config/state.json index 4383b12..86f8cd9 100644 --- a/wled_config/state.json +++ b/wled_config/state.json @@ -1,7 +1,7 @@ { "on": true, - "bri": 254, - "transition": 7, + "bri": 255, + "transition": 0, "ps": -1, "pl": -1, "ledmap": 0, @@ -10,13 +10,13 @@ }, "nl": { "on": false, - "dur": 60, - "mode": 1, - "tbri": 0, + "dur": 30, + "mode": 3, + "tbri": 255, "rem": -1 }, "udpn": { - "send": false, + "send": true, "recv": true, "sgrp": 1, "rgrp": 1 @@ -39,10 +39,10 @@ "set": 0, "col": [ [ - 56, - 56, - 56, - 56 + 112, + 112, + 112, + 112 ], [ 0, diff --git a/wled_config/sunrise_8am.json b/wled_config/sunrise_8am.json new file mode 100644 index 0000000..fb7fcf5 --- /dev/null +++ b/wled_config/sunrise_8am.json @@ -0,0 +1,20 @@ +{ + "on": true, + "bri": 1, + "transition": 0, + "mainseg": 0, + "seg": [ + { + "id": 0, + "on": true, + "fx": 0, + "cct": 0 + } + ], + "nl": { + "on": true, + "dur": 30, + "mode": 3, + "tbri": 255 + } +} diff --git a/wled_config/sunrise_test_10s.json b/wled_config/sunrise_test_10s.json new file mode 100644 index 0000000..26de8d2 --- /dev/null +++ b/wled_config/sunrise_test_10s.json @@ -0,0 +1,17 @@ +{ + "on": true, + "tt": 100, + "bri": 255, + "mainseg": 0, + "nl": { + "on": false + }, + "seg": [ + { + "id": 0, + "on": true, + "fx": 0, + "cct": 0 + } + ] +} diff --git a/wled_config/warm.json b/wled_config/warm.json new file mode 100644 index 0000000..76d2176 --- /dev/null +++ b/wled_config/warm.json @@ -0,0 +1,5 @@ +{ + "on": true, + "bri": 255, + "seg": [{ "id": 0, "fx": 0, "cct": 0 }] +} diff --git a/wled_config/white.json b/wled_config/white.json new file mode 100644 index 0000000..84d762d --- /dev/null +++ b/wled_config/white.json @@ -0,0 +1,5 @@ +{ + "on": true, + "bri": 255, + "seg": [{ "id": 0, "fx": 0, "cct": 255 }] +} diff --git a/wled_push.py b/wled_push.py index e58ad6e..411badb 100644 --- a/wled_push.py +++ b/wled_push.py @@ -55,6 +55,7 @@ def main(): p.add_argument("--off", action="store_true", help="Turn off") p.add_argument("--toggle", "-t", action="store_true", dest="toggle", help="Toggle on/off") p.add_argument("--bri", type=int, metavar="0-255", help="Brightness") + p.add_argument("--cct", type=int, metavar="0-255", help="Color temp: 0=warm, 255=cool white (segment 0)") p.add_argument("--preset", "-p", type=int, metavar="ID", help="Load preset by ID") p.add_argument("--v", action="store_true", help="Return full state in response") args = p.parse_args() @@ -73,6 +74,15 @@ def main(): payload["on"] = "t" if args.bri is not None: payload["bri"] = max(0, min(255, args.bri)) + if args.cct is not None: + cct = max(0, min(255, args.cct)) + payload.setdefault("seg", []) + seg0 = next((s for s in payload["seg"] if s.get("id") == 0), payload["seg"][0] if payload["seg"] else None) + if seg0 is None: + payload["seg"].insert(0, {"id": 0, "fx": 0, "cct": cct}) + else: + seg0["cct"] = cct + seg0["fx"] = 0 # Solid so CCT is visible if args.preset is not None: payload["ps"] = args.preset if args.v: