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