this repo has no description

load lighting strategy from lighting.yaml

+139 -65
+77
lighting.yaml
··· 1 + # solux lighting strategy 2 + # 3 + # philosophy: dim and warm. never harsh or bright. 4 + # lights follow sun position but stay cozy throughout the day. 5 + # 6 + # brightness (bri): 0-254 scale, we cap at 70 7 + # color temp (ct): 153=cool/6500K, 500=warm/2000K, we stay 350-500 8 + # 9 + # phases are anchored to actual sun times (calculated via astral library) 10 + # and interpolate smoothly between each other. 11 + 12 + phases: 13 + dawn: 14 + anchor: dawn 15 + bri: 25 16 + ct: 450 # warm, gentle wake 17 + 18 + sunrise: 19 + anchor: sunrise 20 + bri: 50 21 + ct: 400 22 + 23 + morning: 24 + anchor: sunrise + 1h 25 + bri: 70 # peak brightness 26 + ct: 370 27 + 28 + midday: 29 + anchor: noon 30 + bri: 70 # peak brightness 31 + ct: 350 # warmest daytime, but still cozy 32 + 33 + afternoon: 34 + anchor: noon + 3h 35 + bri: 60 36 + ct: 380 37 + 38 + golden_hour: 39 + anchor: sunset - 1h 40 + bri: 50 41 + ct: 420 42 + 43 + sunset: 44 + anchor: sunset 45 + bri: 40 46 + ct: 450 47 + 48 + dusk: 49 + anchor: dusk 50 + bri: 30 51 + ct: 480 52 + 53 + evening: 54 + anchor: dusk + 1h 55 + bri: 20 56 + ct: 500 # full warm 57 + 58 + night: 59 + anchor: dusk + 3h 60 + bri: 10 61 + ct: 500 # full warm 62 + 63 + # special modes (override circadian rhythm) 64 + modes: 65 + away: 66 + on: false 67 + 68 + sleep: 69 + on: false 70 + 71 + bedtime: 72 + bri: 15 73 + ct: 500 74 + 75 + wake: 76 + bri: 100 77 + ct: 350
+1
pyproject.toml
··· 8 8 "phue2>=0.0.4", 9 9 "prefect>=3.6.10", 10 10 "pydantic-settings>=2.12.0", 11 + "pyyaml>=6.0", 11 12 ] 12 13 13 14 [project.scripts]
+61 -65
src/solux/controller.py
··· 1 1 """time-based lighting controller anchored to sun position.""" 2 2 3 + import re 3 4 from dataclasses import dataclass 4 5 from datetime import datetime, timedelta 6 + from functools import cache 7 + from pathlib import Path 5 8 from typing import Callable 9 + 10 + import yaml 6 11 7 12 from solux import get_bridge 8 13 from solux.state import Mode, load_state, set_mode, override as set_override ··· 27 32 state: LightState 28 33 29 34 30 - # lighting phases anchored to sun position 31 - # dim and warm - ct scale: 153=cool/6500K, 500=warm/2000K 32 - PHASES: list[Phase] = [ 33 - Phase( 34 - name="dawn", 35 - get_start=lambda s: s.dawn, 36 - state=LightState(bri=25, ct=450), 37 - ), 38 - Phase( 39 - name="sunrise", 40 - get_start=lambda s: s.sunrise, 41 - state=LightState(bri=50, ct=400), 42 - ), 43 - Phase( 44 - name="morning", 45 - get_start=lambda s: s.sunrise + timedelta(hours=1), 46 - state=LightState(bri=70, ct=370), 47 - ), 48 - Phase( 49 - name="midday", 50 - get_start=lambda s: s.noon, 51 - state=LightState(bri=70, ct=350), 52 - ), 53 - Phase( 54 - name="afternoon", 55 - get_start=lambda s: s.noon + timedelta(hours=3), 56 - state=LightState(bri=60, ct=380), 57 - ), 58 - Phase( 59 - name="golden_hour", 60 - get_start=lambda s: s.sunset - timedelta(hours=1), 61 - state=LightState(bri=50, ct=420), 62 - ), 63 - Phase( 64 - name="sunset", 65 - get_start=lambda s: s.sunset, 66 - state=LightState(bri=40, ct=450), 67 - ), 68 - Phase( 69 - name="dusk", 70 - get_start=lambda s: s.dusk, 71 - state=LightState(bri=30, ct=480), 72 - ), 73 - Phase( 74 - name="evening", 75 - get_start=lambda s: s.dusk + timedelta(hours=1), 76 - state=LightState(bri=20, ct=500), 77 - ), 78 - Phase( 79 - name="night", 80 - get_start=lambda s: s.dusk + timedelta(hours=3), 81 - state=LightState(bri=10, ct=500), 82 - ), 83 - ] 35 + def _parse_anchor(anchor: str) -> Callable[[SunTimes], datetime]: 36 + """parse anchor expression like 'sunrise + 1h' into a function.""" 37 + anchor = anchor.strip() 84 38 85 - # light states for non-auto modes 86 - MODE_STATES: dict[Mode, LightState] = { 87 - Mode.AWAY: LightState(bri=0, ct=500, on=False), 88 - Mode.SLEEP: LightState(bri=0, ct=500, on=False), 89 - Mode.BEDTIME: LightState(bri=15, ct=500), 90 - Mode.WAKE: LightState(bri=100, ct=350), 91 - } 39 + # match patterns like "sunrise + 1h" or "noon - 30m" 40 + match = re.match(r"(\w+)\s*([+-])\s*(\d+)([hm])", anchor) 41 + if match: 42 + base, op, num, unit = match.groups() 43 + delta = timedelta(hours=int(num)) if unit == "h" else timedelta(minutes=int(num)) 44 + if op == "-": 45 + delta = -delta 46 + return lambda s, b=base, d=delta: getattr(s, b) + d 47 + 48 + # simple anchor like "dawn" 49 + return lambda s, a=anchor: getattr(s, a) 50 + 51 + 52 + @cache 53 + def load_config() -> dict: 54 + """load lighting config from yaml.""" 55 + # check cwd first (for git clone deployments), then package location 56 + for path in [Path.cwd() / "lighting.yaml", Path(__file__).parent.parent.parent / "lighting.yaml"]: 57 + if path.exists(): 58 + return yaml.safe_load(path.read_text()) 59 + raise FileNotFoundError("lighting.yaml not found") 60 + 61 + 62 + def get_phases() -> list[Phase]: 63 + """build phases from config.""" 64 + config = load_config() 65 + phases = [] 66 + for name, spec in config.get("phases", {}).items(): 67 + phases.append(Phase( 68 + name=name, 69 + get_start=_parse_anchor(spec["anchor"]), 70 + state=LightState(bri=spec["bri"], ct=spec["ct"]), 71 + )) 72 + return phases 73 + 74 + 75 + def get_mode_states() -> dict[Mode, LightState]: 76 + """build mode states from config.""" 77 + config = load_config() 78 + states = {} 79 + for mode_name, spec in config.get("modes", {}).items(): 80 + mode = Mode(mode_name) 81 + states[mode] = LightState( 82 + bri=spec.get("bri", 0), 83 + ct=spec.get("ct", 500), 84 + on=spec.get("on", True), 85 + ) 86 + return states 92 87 93 88 94 89 def get_current_phase_and_progress( ··· 99 94 sun = get_sun_times(now.date()) 100 95 101 96 phase_times: list[tuple[datetime, Phase]] = [] 102 - for phase in PHASES: 97 + for phase in get_phases(): 103 98 start = phase.get_start(sun) 104 99 if start.tzinfo is None: 105 100 start = start.replace(tzinfo=now.tzinfo) ··· 158 153 on=external.on if external.on is not None else circadian.on, 159 154 ), f"override by {external.updated_by}" 160 155 161 - if external.mode in MODE_STATES: 162 - return MODE_STATES[external.mode], f"{external.mode.value} by {external.updated_by}" 156 + mode_states = get_mode_states() 157 + if external.mode in mode_states: 158 + return mode_states[external.mode], f"{external.mode.value} by {external.updated_by}" 163 159 164 160 return get_circadian_state(), "auto (fallback)" 165 161 ··· 280 276 print(f" dusk: {sun.dusk.strftime('%H:%M')}") 281 277 print() 282 278 print("phases:") 283 - for phase in PHASES: 279 + for phase in get_phases(): 284 280 start = phase.get_start(sun) 285 281 print(f" {start.strftime('%H:%M')} - {phase.name}: bri={phase.state.bri}, ct={phase.state.ct}") 286 282