this repo has no description

initial commit - circadian lighting for philips hue

+685
+8
.gitignore
··· 1 + .venv/ 2 + .env 3 + __pycache__/ 4 + *.pyc 5 + *.egg-info/ 6 + dist/ 7 + build/ 8 + uv.lock
+1
.python-version
··· 1 + 3.14
+83
README.md
··· 1 + # solux 2 + 3 + circadian lighting for philips hue. 4 + 5 + automatically adjusts lights based on sun position. provides entry points for external systems (geofencing, bedtime routines, etc.) to override behavior. 6 + 7 + ## install 8 + 9 + ```bash 10 + uv add solux 11 + ``` 12 + 13 + requires `HUE_BRIDGE_IP` and `HUE_BRIDGE_USERNAME` environment variables. 14 + 15 + ## cli 16 + 17 + ```bash 18 + solux # run once - apply current circadian state 19 + solux run -d # run as daemon (updates every 15m) 20 + solux status # show current mode and light state 21 + solux schedule # show today's lighting phases 22 + 23 + solux set <mode> # set mode: auto, away, bedtime, sleep, wake 24 + solux set away --by geofence 25 + solux set auto --by geofence # "coming home" 26 + solux set wake --expires 60 # auto-expires in 60 minutes 27 + 28 + solux override --bri 50 # custom brightness 29 + solux override --off --expires 30 # lights off for 30 minutes 30 + ``` 31 + 32 + ## python api 33 + 34 + ```python 35 + from solux.state import Mode, set_mode, override, load_state 36 + 37 + # set mode (primary entry point for external systems) 38 + set_mode(Mode.AWAY, by="geofence") 39 + set_mode(Mode.AUTO, by="geofence") # arriving home 40 + set_mode(Mode.BEDTIME, by="shortcut") 41 + set_mode(Mode.WAKE, by="alarm", expires_in_minutes=60) 42 + 43 + # custom override 44 + override(brightness=50, by="movie-mode", expires_in_minutes=120) 45 + override(on=False, groups=["bedroom"], by="goodnight") 46 + 47 + # read current state 48 + state = load_state() 49 + print(state.mode, state.updated_by) 50 + ``` 51 + 52 + ## state file 53 + 54 + external systems can write directly to `~/.solux/state.json`: 55 + 56 + ```json 57 + { 58 + "mode": "away", 59 + "updated_by": "geofence", 60 + "updated_at": "2024-01-10T20:00:00-06:00", 61 + "expires_at": null 62 + } 63 + ``` 64 + 65 + ## modes 66 + 67 + | mode | behavior | 68 + |------|----------| 69 + | `auto` | normal circadian rhythm | 70 + | `away` | lights off (not home) | 71 + | `bedtime` | dim nightlight | 72 + | `sleep` | lights off | 73 + | `wake` | gentle warm light | 74 + | `override` | custom brightness/color temp | 75 + 76 + ## phases 77 + 78 + lighting phases are anchored to actual sun position (adapts to seasons): 79 + 80 + - **dawn** → **sunrise** → **morning** → **midday** → **afternoon** 81 + - **golden_hour** → **sunset** → **dusk** → **evening** → **night** 82 + 83 + the controller interpolates smoothly between phases.
+9
prefect.yaml
··· 1 + name: solux 2 + 3 + deployments: 4 + - name: circadian-lights 5 + entrypoint: src/solux/flow.py:solux_update 6 + work_pool: 7 + name: default 8 + schedules: 9 + - interval: 900 # 15 minutes
+21
pyproject.toml
··· 1 + [project] 2 + name = "solux" 3 + description = "circadian lighting for philips hue" 4 + requires-python = ">=3.14" 5 + dynamic = ["version"] 6 + dependencies = [ 7 + "astral>=3.2", 8 + "phue2>=0.0.4", 9 + "prefect>=3.6.10", 10 + "pydantic-settings>=2.12.0", 11 + ] 12 + 13 + [project.scripts] 14 + solux = "solux.controller:main" 15 + 16 + [tool.hatch.version] 17 + source = "vcs" 18 + 19 + [build-system] 20 + requires = ["hatchling", "hatch-vcs"] 21 + build-backend = "hatchling.build"
+28
src/solux/__init__.py
··· 1 + """solux - circadian lighting control for philips hue.""" 2 + 3 + from pathlib import Path 4 + 5 + from phue import Bridge 6 + from pydantic import Field 7 + from pydantic_settings import BaseSettings, SettingsConfigDict 8 + 9 + 10 + class Settings(BaseSettings): 11 + model_config = SettingsConfigDict( 12 + env_file=Path(__file__).parent.parent.parent / ".env", 13 + extra="ignore", 14 + ) 15 + 16 + hue_bridge_ip: str = Field(default=...) 17 + hue_bridge_username: str = Field(default=...) 18 + 19 + 20 + settings = Settings() 21 + 22 + 23 + def get_bridge() -> Bridge: 24 + return Bridge( 25 + ip=settings.hue_bridge_ip, 26 + username=settings.hue_bridge_username, 27 + save_config=False, 28 + )
+315
src/solux/controller.py
··· 1 + """time-based lighting controller anchored to sun position.""" 2 + 3 + from dataclasses import dataclass 4 + from datetime import datetime, timedelta 5 + from typing import Callable 6 + 7 + from solux import get_bridge 8 + from solux.state import Mode, load_state, set_mode, override as set_override 9 + from solux.sun import get_sun_times, SunTimes 10 + 11 + 12 + @dataclass 13 + class LightState: 14 + """light settings.""" 15 + 16 + bri: int # 0-254 17 + ct: int # 153 (cool/6500K) to 500 (warm/2000K) 18 + on: bool = True 19 + 20 + 21 + @dataclass 22 + class Phase: 23 + """a lighting phase with start time and target state.""" 24 + 25 + name: str 26 + get_start: Callable[[SunTimes], datetime] 27 + state: LightState 28 + 29 + 30 + # lighting phases anchored to sun position 31 + PHASES: list[Phase] = [ 32 + Phase( 33 + name="dawn", 34 + get_start=lambda s: s.dawn, 35 + state=LightState(bri=80, ct=400), 36 + ), 37 + Phase( 38 + name="sunrise", 39 + get_start=lambda s: s.sunrise, 40 + state=LightState(bri=180, ct=300), 41 + ), 42 + Phase( 43 + name="morning", 44 + get_start=lambda s: s.sunrise + timedelta(hours=1), 45 + state=LightState(bri=254, ct=220), 46 + ), 47 + Phase( 48 + name="midday", 49 + get_start=lambda s: s.noon, 50 + state=LightState(bri=254, ct=200), 51 + ), 52 + Phase( 53 + name="afternoon", 54 + get_start=lambda s: s.noon + timedelta(hours=3), 55 + state=LightState(bri=230, ct=280), 56 + ), 57 + Phase( 58 + name="golden_hour", 59 + get_start=lambda s: s.sunset - timedelta(hours=1), 60 + state=LightState(bri=180, ct=370), 61 + ), 62 + Phase( 63 + name="sunset", 64 + get_start=lambda s: s.sunset, 65 + state=LightState(bri=120, ct=420), 66 + ), 67 + Phase( 68 + name="dusk", 69 + get_start=lambda s: s.dusk, 70 + state=LightState(bri=80, ct=470), 71 + ), 72 + Phase( 73 + name="evening", 74 + get_start=lambda s: s.dusk + timedelta(hours=1), 75 + state=LightState(bri=50, ct=500), 76 + ), 77 + Phase( 78 + name="night", 79 + get_start=lambda s: s.dusk + timedelta(hours=3), 80 + state=LightState(bri=25, ct=500), 81 + ), 82 + ] 83 + 84 + # light states for non-auto modes 85 + MODE_STATES: dict[Mode, LightState] = { 86 + Mode.AWAY: LightState(bri=0, ct=500, on=False), 87 + Mode.SLEEP: LightState(bri=0, ct=500, on=False), 88 + Mode.BEDTIME: LightState(bri=15, ct=500), 89 + Mode.WAKE: LightState(bri=100, ct=350), 90 + } 91 + 92 + 93 + def get_current_phase_and_progress( 94 + now: datetime | None = None, 95 + ) -> tuple[Phase, Phase, float]: 96 + """determine current and next phase, plus progress between them.""" 97 + now = now or datetime.now().astimezone() 98 + sun = get_sun_times(now.date()) 99 + 100 + phase_times: list[tuple[datetime, Phase]] = [] 101 + for phase in PHASES: 102 + start = phase.get_start(sun) 103 + if start.tzinfo is None: 104 + start = start.replace(tzinfo=now.tzinfo) 105 + phase_times.append((start, phase)) 106 + 107 + phase_times.sort(key=lambda x: x[0]) 108 + 109 + current_idx = 0 110 + for i, (start_time, _) in enumerate(phase_times): 111 + if start_time <= now: 112 + current_idx = i 113 + 114 + next_idx = (current_idx + 1) % len(phase_times) 115 + 116 + current_time, current_phase = phase_times[current_idx] 117 + next_time, next_phase = phase_times[next_idx] 118 + 119 + if next_time <= current_time: 120 + next_time = next_time + timedelta(days=1) 121 + 122 + total_duration = (next_time - current_time).total_seconds() 123 + elapsed = (now - current_time).total_seconds() 124 + 125 + progress = max(0.0, min(1.0, elapsed / total_duration)) if total_duration > 0 else 0.0 126 + 127 + return current_phase, next_phase, progress 128 + 129 + 130 + def interpolate_state(current: LightState, target: LightState, progress: float) -> LightState: 131 + """interpolate between two light states.""" 132 + return LightState( 133 + bri=int(current.bri + (target.bri - current.bri) * progress), 134 + ct=int(current.ct + (target.ct - current.ct) * progress), 135 + on=current.on and target.on, 136 + ) 137 + 138 + 139 + def get_circadian_state() -> LightState: 140 + """get the circadian light state for right now.""" 141 + current_phase, next_phase, progress = get_current_phase_and_progress() 142 + return interpolate_state(current_phase.state, next_phase.state, progress) 143 + 144 + 145 + def resolve_state(external) -> tuple[LightState, str]: 146 + """resolve external state + circadian into final light state.""" 147 + if external.mode == Mode.AUTO: 148 + current, next_phase, progress = get_current_phase_and_progress() 149 + state = interpolate_state(current.state, next_phase.state, progress) 150 + return state, f"auto [{current.name} -> {next_phase.name}] {progress:.0%}" 151 + 152 + if external.mode == Mode.OVERRIDE: 153 + circadian = get_circadian_state() 154 + return LightState( 155 + bri=external.brightness if external.brightness is not None else circadian.bri, 156 + ct=external.color_temp if external.color_temp is not None else circadian.ct, 157 + on=external.on if external.on is not None else circadian.on, 158 + ), f"override by {external.updated_by}" 159 + 160 + if external.mode in MODE_STATES: 161 + return MODE_STATES[external.mode], f"{external.mode.value} by {external.updated_by}" 162 + 163 + return get_circadian_state(), "auto (fallback)" 164 + 165 + 166 + def apply_state( 167 + light_state: LightState, 168 + groups: list[str] | None = None, 169 + transition_seconds: float = 60.0, 170 + ) -> None: 171 + """apply light state to groups.""" 172 + bridge = get_bridge() 173 + 174 + if groups is None: 175 + all_groups = bridge.get_group() 176 + groups = [ 177 + info["name"] 178 + for gid, info in all_groups.items() 179 + if info["name"] not in ("all", "Custom group for $lights") 180 + ] 181 + 182 + for group_name in groups: 183 + bridge.set_group( 184 + group_name, 185 + {"on": light_state.on, "bri": light_state.bri, "ct": light_state.ct}, 186 + transitiontime=int(transition_seconds * 10), 187 + ) 188 + 189 + 190 + def update(groups: list[str] | None = None) -> None: 191 + """main update - checks state, applies lights.""" 192 + external = load_state() 193 + light_state, description = resolve_state(external) 194 + target_groups = external.groups or groups 195 + 196 + print(f"[{description}] bri={light_state.bri}, ct={light_state.ct}, on={light_state.on} | {target_groups}") 197 + apply_state(light_state, target_groups) 198 + 199 + 200 + def run(interval_minutes: int = 15, groups: list[str] | None = None) -> None: 201 + """run continuously.""" 202 + import time 203 + 204 + print(f"solux daemon started (updating every {interval_minutes}m)") 205 + 206 + sun = get_sun_times() 207 + print(f"dawn: {sun.dawn.strftime('%H:%M')}, sunrise: {sun.sunrise.strftime('%H:%M')}, " 208 + f"sunset: {sun.sunset.strftime('%H:%M')}, dusk: {sun.dusk.strftime('%H:%M')}") 209 + 210 + while True: 211 + try: 212 + update(groups) 213 + except Exception as e: 214 + print(f"error: {e}") 215 + time.sleep(interval_minutes * 60) 216 + 217 + 218 + def main() -> None: 219 + """cli entry point.""" 220 + import argparse 221 + 222 + parser = argparse.ArgumentParser( 223 + description="solux - circadian lighting for philips hue", 224 + formatter_class=argparse.RawDescriptionHelpFormatter, 225 + ) 226 + subparsers = parser.add_subparsers(dest="command", help="commands") 227 + 228 + # run (default when no command given) 229 + run_parser = subparsers.add_parser("run", help="run controller") 230 + run_parser.add_argument("-d", "--daemon", action="store_true", help="run continuously") 231 + run_parser.add_argument("-i", "--interval", type=int, default=15, help="update interval in minutes") 232 + run_parser.add_argument("-g", "--groups", nargs="+", help="specific groups to control") 233 + 234 + # status 235 + subparsers.add_parser("status", help="show current state and phase") 236 + 237 + # schedule 238 + subparsers.add_parser("schedule", help="show today's lighting schedule") 239 + 240 + # set <mode> 241 + set_parser = subparsers.add_parser("set", help="set mode: auto, away, bedtime, sleep, wake") 242 + set_parser.add_argument("mode", choices=["auto", "away", "bedtime", "sleep", "wake"]) 243 + set_parser.add_argument("--by", default="cli", help="source identifier") 244 + set_parser.add_argument("--expires", type=int, help="expire in N minutes") 245 + 246 + # override 247 + override_parser = subparsers.add_parser("override", help="manual override with custom values") 248 + override_parser.add_argument("--bri", type=int, help="brightness (0-254)") 249 + override_parser.add_argument("--ct", type=int, help="color temp (153-500)") 250 + override_parser.add_argument("--off", action="store_true", help="turn lights off") 251 + override_parser.add_argument("-g", "--groups", nargs="+", help="specific groups") 252 + override_parser.add_argument("--by", default="cli", help="source identifier") 253 + override_parser.add_argument("--expires", type=int, help="expire in N minutes") 254 + 255 + args = parser.parse_args() 256 + 257 + if args.command == "status": 258 + ext = load_state() 259 + light_state, desc = resolve_state(ext) 260 + 261 + print(f"mode: {ext.mode.value}") 262 + if ext.updated_by: 263 + print(f"set by: {ext.updated_by}") 264 + if ext.updated_at: 265 + print(f"updated: {ext.updated_at}") 266 + if ext.expires_at: 267 + print(f"expires: {ext.expires_at}") 268 + print() 269 + print(f"resolved: {desc}") 270 + print(f" bri={light_state.bri}, ct={light_state.ct}, on={light_state.on}") 271 + 272 + elif args.command == "schedule": 273 + sun = get_sun_times() 274 + print(f"sun times for {sun.dawn.date()}:") 275 + print(f" dawn: {sun.dawn.strftime('%H:%M')}") 276 + print(f" sunrise: {sun.sunrise.strftime('%H:%M')}") 277 + print(f" noon: {sun.noon.strftime('%H:%M')}") 278 + print(f" sunset: {sun.sunset.strftime('%H:%M')}") 279 + print(f" dusk: {sun.dusk.strftime('%H:%M')}") 280 + print() 281 + print("phases:") 282 + for phase in PHASES: 283 + start = phase.get_start(sun) 284 + print(f" {start.strftime('%H:%M')} - {phase.name}: bri={phase.state.bri}, ct={phase.state.ct}") 285 + 286 + elif args.command == "set": 287 + set_mode(args.mode, by=args.by, expires_in_minutes=args.expires) 288 + print(f"mode: {args.mode}") 289 + update() 290 + 291 + elif args.command == "override": 292 + set_override( 293 + brightness=args.bri, 294 + color_temp=args.ct, 295 + on=False if args.off else None, 296 + groups=args.groups, 297 + by=args.by, 298 + expires_in_minutes=args.expires, 299 + ) 300 + print(f"override set (by {args.by})") 301 + update() 302 + 303 + elif args.command == "run": 304 + if args.daemon: 305 + run(args.interval, args.groups) 306 + else: 307 + update(args.groups) 308 + 309 + else: 310 + # no command = run once 311 + update() 312 + 313 + 314 + if __name__ == "__main__": 315 + main()
+14
src/solux/flow.py
··· 1 + """prefect flow for scheduled light updates.""" 2 + 3 + from prefect import flow 4 + 5 + 6 + @flow(log_prints=True) 7 + def solux_update(): 8 + """update lights to current circadian state.""" 9 + from solux.controller import update 10 + update() 11 + 12 + 13 + if __name__ == "__main__": 14 + solux_update()
+146
src/solux/state.py
··· 1 + """external state management - entry points for other systems.""" 2 + 3 + import json 4 + from dataclasses import dataclass, field, asdict 5 + from datetime import datetime, timedelta 6 + from enum import Enum 7 + from pathlib import Path 8 + from typing import Any 9 + 10 + 11 + class Mode(str, Enum): 12 + """operating modes.""" 13 + 14 + AUTO = "auto" # normal circadian behavior 15 + AWAY = "away" # not home - lights off 16 + BEDTIME = "bedtime" # winding down, minimal light 17 + SLEEP = "sleep" # asleep - lights off 18 + WAKE = "wake" # waking up - gentle ramp 19 + OVERRIDE = "override" # manual override with custom state 20 + 21 + 22 + @dataclass 23 + class State: 24 + """current system state - can be set by external systems.""" 25 + 26 + mode: Mode = Mode.AUTO 27 + updated_at: str = "" 28 + updated_by: str = "" # what set this (e.g., "geofence", "cli", "bedtime-routine") 29 + 30 + # overrides (used when mode is OVERRIDE, or to customize other modes) 31 + brightness: int | None = None # 0-254 32 + color_temp: int | None = None # 153-500 33 + on: bool | None = None 34 + 35 + # which groups to affect (None = all) 36 + groups: list[str] | None = None 37 + 38 + # auto-expire this state 39 + expires_at: str | None = None 40 + 41 + # metadata for debugging/observability 42 + metadata: dict[str, Any] = field(default_factory=dict) 43 + 44 + 45 + # state file location 46 + STATE_FILE = Path.home() / ".solux" / "state.json" 47 + 48 + 49 + def _ensure_state_dir() -> None: 50 + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) 51 + 52 + 53 + def load_state() -> State: 54 + """load current state from file.""" 55 + _ensure_state_dir() 56 + 57 + if not STATE_FILE.exists(): 58 + return State() 59 + 60 + try: 61 + data = json.loads(STATE_FILE.read_text()) 62 + 63 + # check expiration 64 + if data.get("expires_at"): 65 + expires = datetime.fromisoformat(data["expires_at"]) 66 + if datetime.now().astimezone() > expires: 67 + return State() # expired - return to auto 68 + 69 + return State( 70 + mode=Mode(data.get("mode", "auto")), 71 + updated_at=data.get("updated_at", ""), 72 + updated_by=data.get("updated_by", ""), 73 + brightness=data.get("brightness"), 74 + color_temp=data.get("color_temp"), 75 + on=data.get("on"), 76 + groups=data.get("groups"), 77 + expires_at=data.get("expires_at"), 78 + metadata=data.get("metadata", {}), 79 + ) 80 + except (json.JSONDecodeError, ValueError): 81 + return State() 82 + 83 + 84 + def set_mode( 85 + mode: Mode | str, 86 + by: str = "manual", 87 + expires_in_minutes: int | None = None, 88 + **kwargs: Any, 89 + ) -> State: 90 + """set operating mode. 91 + 92 + primary entry point for external systems. 93 + 94 + examples: 95 + set_mode(Mode.AWAY, by="geofence") 96 + set_mode(Mode.AUTO, by="geofence") # arriving home 97 + set_mode(Mode.BEDTIME, by="shortcut") 98 + set_mode(Mode.WAKE, by="alarm", expires_in_minutes=60) 99 + """ 100 + if isinstance(mode, str): 101 + mode = Mode(mode) 102 + 103 + state = State( 104 + mode=mode, 105 + updated_by=by, 106 + metadata=kwargs.pop("metadata", {}), 107 + **kwargs, 108 + ) 109 + 110 + if expires_in_minutes: 111 + expires = datetime.now().astimezone() + timedelta(minutes=expires_in_minutes) 112 + state.expires_at = expires.isoformat() 113 + 114 + _ensure_state_dir() 115 + state.updated_at = datetime.now().astimezone().isoformat() 116 + 117 + data = asdict(state) 118 + data["mode"] = state.mode.value 119 + STATE_FILE.write_text(json.dumps(data, indent=2)) 120 + 121 + return state 122 + 123 + 124 + def override( 125 + brightness: int | None = None, 126 + color_temp: int | None = None, 127 + on: bool | None = None, 128 + groups: list[str] | None = None, 129 + by: str = "manual", 130 + expires_in_minutes: int | None = None, 131 + ) -> State: 132 + """manual override with custom light values. 133 + 134 + examples: 135 + override(brightness=50, by="movie-mode", expires_in_minutes=120) 136 + override(on=False, groups=["bedroom"], by="goodnight") 137 + """ 138 + return set_mode( 139 + Mode.OVERRIDE, 140 + by=by, 141 + brightness=brightness, 142 + color_temp=color_temp, 143 + on=on, 144 + groups=groups, 145 + expires_in_minutes=expires_in_minutes, 146 + )
+60
src/solux/sun.py
··· 1 + """sun position calculations.""" 2 + 3 + from dataclasses import dataclass 4 + from datetime import datetime, date 5 + from zoneinfo import ZoneInfo 6 + 7 + from astral import LocationInfo 8 + from astral.sun import sun 9 + 10 + 11 + @dataclass 12 + class SunTimes: 13 + """sun times for a given day, in local time.""" 14 + 15 + dawn: datetime 16 + sunrise: datetime 17 + noon: datetime 18 + sunset: datetime 19 + dusk: datetime 20 + 21 + @classmethod 22 + def for_location( 23 + cls, 24 + lat: float, 25 + lon: float, 26 + timezone: str, 27 + day: date | None = None, 28 + ) -> "SunTimes": 29 + """calculate sun times for a location.""" 30 + day = day or date.today() 31 + tz = ZoneInfo(timezone) 32 + 33 + location = LocationInfo( 34 + name="", 35 + region="", 36 + timezone=timezone, 37 + latitude=lat, 38 + longitude=lon, 39 + ) 40 + 41 + s = sun(location.observer, date=day, tzinfo=tz) 42 + 43 + return cls( 44 + dawn=s["dawn"], 45 + sunrise=s["sunrise"], 46 + noon=s["noon"], 47 + sunset=s["sunset"], 48 + dusk=s["dusk"], 49 + ) 50 + 51 + 52 + # default location - can be overridden via settings 53 + DEFAULT_LAT = 41.8781 54 + DEFAULT_LON = -87.6298 55 + DEFAULT_TZ = "America/Chicago" 56 + 57 + 58 + def get_sun_times(day: date | None = None) -> SunTimes: 59 + """get sun times for today (or specified day) at default location.""" 60 + return SunTimes.for_location(DEFAULT_LAT, DEFAULT_LON, DEFAULT_TZ, day)