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
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
+17
-17
@@ -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": [
|
||||
"<button class=\"btn btn-xs\" onclick=\"requestJson({AudioReactive:{enabled:false}});\"><i class=\"icons on\"></i></button>"
|
||||
@@ -143,13 +143,13 @@
|
||||
],
|
||||
"Audio Source": [
|
||||
"I2S digital",
|
||||
" - peak 99%"
|
||||
" - quiet"
|
||||
],
|
||||
"Sound Processing": [
|
||||
"running"
|
||||
],
|
||||
"AGC Gain": [
|
||||
8.46,
|
||||
5.36,
|
||||
"x"
|
||||
],
|
||||
"UDP Sound Sync": [
|
||||
|
||||
+10
-10
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"on": true,
|
||||
"tt": 100,
|
||||
"bri": 255,
|
||||
"mainseg": 0,
|
||||
"nl": {
|
||||
"on": false
|
||||
},
|
||||
"seg": [
|
||||
{
|
||||
"id": 0,
|
||||
"on": true,
|
||||
"fx": 0,
|
||||
"cct": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"on": true,
|
||||
"bri": 255,
|
||||
"seg": [{ "id": 0, "fx": 0, "cct": 0 }]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"on": true,
|
||||
"bri": 255,
|
||||
"seg": [{ "id": 0, "fx": 0, "cct": 255 }]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user