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:
2026-03-09 00:50:35 +08:00
parent 3812dbc8ad
commit 3a40741096
9 changed files with 345 additions and 27 deletions
+88
View File
@@ -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
```
+173
View File
@@ -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
View File
@@ -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\">&#xe08f;</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
View File
@@ -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,
+20
View File
@@ -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
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"on": true,
"tt": 100,
"bri": 255,
"mainseg": 0,
"nl": {
"on": false
},
"seg": [
{
"id": 0,
"on": true,
"fx": 0,
"cct": 0
}
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"on": true,
"bri": 255,
"seg": [{ "id": 0, "fx": 0, "cct": 0 }]
}
+5
View File
@@ -0,0 +1,5 @@
{
"on": true,
"bri": 255,
"seg": [{ "id": 0, "fx": 0, "cct": 255 }]
}
+10
View File
@@ -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: