tangled
alpha
login
or
join now
zzstoatzz.io
/
solux
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
load lighting strategy from lighting.yaml
zzstoatzz.io
1 month ago
a643937d
b5cfd58b
+139
-65
3 changed files
expand all
collapse all
unified
split
lighting.yaml
pyproject.toml
src
solux
controller.py
+77
lighting.yaml
···
1
1
+
# solux lighting strategy
2
2
+
#
3
3
+
# philosophy: dim and warm. never harsh or bright.
4
4
+
# lights follow sun position but stay cozy throughout the day.
5
5
+
#
6
6
+
# brightness (bri): 0-254 scale, we cap at 70
7
7
+
# color temp (ct): 153=cool/6500K, 500=warm/2000K, we stay 350-500
8
8
+
#
9
9
+
# phases are anchored to actual sun times (calculated via astral library)
10
10
+
# and interpolate smoothly between each other.
11
11
+
12
12
+
phases:
13
13
+
dawn:
14
14
+
anchor: dawn
15
15
+
bri: 25
16
16
+
ct: 450 # warm, gentle wake
17
17
+
18
18
+
sunrise:
19
19
+
anchor: sunrise
20
20
+
bri: 50
21
21
+
ct: 400
22
22
+
23
23
+
morning:
24
24
+
anchor: sunrise + 1h
25
25
+
bri: 70 # peak brightness
26
26
+
ct: 370
27
27
+
28
28
+
midday:
29
29
+
anchor: noon
30
30
+
bri: 70 # peak brightness
31
31
+
ct: 350 # warmest daytime, but still cozy
32
32
+
33
33
+
afternoon:
34
34
+
anchor: noon + 3h
35
35
+
bri: 60
36
36
+
ct: 380
37
37
+
38
38
+
golden_hour:
39
39
+
anchor: sunset - 1h
40
40
+
bri: 50
41
41
+
ct: 420
42
42
+
43
43
+
sunset:
44
44
+
anchor: sunset
45
45
+
bri: 40
46
46
+
ct: 450
47
47
+
48
48
+
dusk:
49
49
+
anchor: dusk
50
50
+
bri: 30
51
51
+
ct: 480
52
52
+
53
53
+
evening:
54
54
+
anchor: dusk + 1h
55
55
+
bri: 20
56
56
+
ct: 500 # full warm
57
57
+
58
58
+
night:
59
59
+
anchor: dusk + 3h
60
60
+
bri: 10
61
61
+
ct: 500 # full warm
62
62
+
63
63
+
# special modes (override circadian rhythm)
64
64
+
modes:
65
65
+
away:
66
66
+
on: false
67
67
+
68
68
+
sleep:
69
69
+
on: false
70
70
+
71
71
+
bedtime:
72
72
+
bri: 15
73
73
+
ct: 500
74
74
+
75
75
+
wake:
76
76
+
bri: 100
77
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
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
3
+
import re
3
4
from dataclasses import dataclass
4
5
from datetime import datetime, timedelta
6
6
+
from functools import cache
7
7
+
from pathlib import Path
5
8
from typing import Callable
9
9
+
10
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
30
-
# lighting phases anchored to sun position
31
31
-
# dim and warm - ct scale: 153=cool/6500K, 500=warm/2000K
32
32
-
PHASES: list[Phase] = [
33
33
-
Phase(
34
34
-
name="dawn",
35
35
-
get_start=lambda s: s.dawn,
36
36
-
state=LightState(bri=25, ct=450),
37
37
-
),
38
38
-
Phase(
39
39
-
name="sunrise",
40
40
-
get_start=lambda s: s.sunrise,
41
41
-
state=LightState(bri=50, ct=400),
42
42
-
),
43
43
-
Phase(
44
44
-
name="morning",
45
45
-
get_start=lambda s: s.sunrise + timedelta(hours=1),
46
46
-
state=LightState(bri=70, ct=370),
47
47
-
),
48
48
-
Phase(
49
49
-
name="midday",
50
50
-
get_start=lambda s: s.noon,
51
51
-
state=LightState(bri=70, ct=350),
52
52
-
),
53
53
-
Phase(
54
54
-
name="afternoon",
55
55
-
get_start=lambda s: s.noon + timedelta(hours=3),
56
56
-
state=LightState(bri=60, ct=380),
57
57
-
),
58
58
-
Phase(
59
59
-
name="golden_hour",
60
60
-
get_start=lambda s: s.sunset - timedelta(hours=1),
61
61
-
state=LightState(bri=50, ct=420),
62
62
-
),
63
63
-
Phase(
64
64
-
name="sunset",
65
65
-
get_start=lambda s: s.sunset,
66
66
-
state=LightState(bri=40, ct=450),
67
67
-
),
68
68
-
Phase(
69
69
-
name="dusk",
70
70
-
get_start=lambda s: s.dusk,
71
71
-
state=LightState(bri=30, ct=480),
72
72
-
),
73
73
-
Phase(
74
74
-
name="evening",
75
75
-
get_start=lambda s: s.dusk + timedelta(hours=1),
76
76
-
state=LightState(bri=20, ct=500),
77
77
-
),
78
78
-
Phase(
79
79
-
name="night",
80
80
-
get_start=lambda s: s.dusk + timedelta(hours=3),
81
81
-
state=LightState(bri=10, ct=500),
82
82
-
),
83
83
-
]
35
35
+
def _parse_anchor(anchor: str) -> Callable[[SunTimes], datetime]:
36
36
+
"""parse anchor expression like 'sunrise + 1h' into a function."""
37
37
+
anchor = anchor.strip()
84
38
85
85
-
# light states for non-auto modes
86
86
-
MODE_STATES: dict[Mode, LightState] = {
87
87
-
Mode.AWAY: LightState(bri=0, ct=500, on=False),
88
88
-
Mode.SLEEP: LightState(bri=0, ct=500, on=False),
89
89
-
Mode.BEDTIME: LightState(bri=15, ct=500),
90
90
-
Mode.WAKE: LightState(bri=100, ct=350),
91
91
-
}
39
39
+
# match patterns like "sunrise + 1h" or "noon - 30m"
40
40
+
match = re.match(r"(\w+)\s*([+-])\s*(\d+)([hm])", anchor)
41
41
+
if match:
42
42
+
base, op, num, unit = match.groups()
43
43
+
delta = timedelta(hours=int(num)) if unit == "h" else timedelta(minutes=int(num))
44
44
+
if op == "-":
45
45
+
delta = -delta
46
46
+
return lambda s, b=base, d=delta: getattr(s, b) + d
47
47
+
48
48
+
# simple anchor like "dawn"
49
49
+
return lambda s, a=anchor: getattr(s, a)
50
50
+
51
51
+
52
52
+
@cache
53
53
+
def load_config() -> dict:
54
54
+
"""load lighting config from yaml."""
55
55
+
# check cwd first (for git clone deployments), then package location
56
56
+
for path in [Path.cwd() / "lighting.yaml", Path(__file__).parent.parent.parent / "lighting.yaml"]:
57
57
+
if path.exists():
58
58
+
return yaml.safe_load(path.read_text())
59
59
+
raise FileNotFoundError("lighting.yaml not found")
60
60
+
61
61
+
62
62
+
def get_phases() -> list[Phase]:
63
63
+
"""build phases from config."""
64
64
+
config = load_config()
65
65
+
phases = []
66
66
+
for name, spec in config.get("phases", {}).items():
67
67
+
phases.append(Phase(
68
68
+
name=name,
69
69
+
get_start=_parse_anchor(spec["anchor"]),
70
70
+
state=LightState(bri=spec["bri"], ct=spec["ct"]),
71
71
+
))
72
72
+
return phases
73
73
+
74
74
+
75
75
+
def get_mode_states() -> dict[Mode, LightState]:
76
76
+
"""build mode states from config."""
77
77
+
config = load_config()
78
78
+
states = {}
79
79
+
for mode_name, spec in config.get("modes", {}).items():
80
80
+
mode = Mode(mode_name)
81
81
+
states[mode] = LightState(
82
82
+
bri=spec.get("bri", 0),
83
83
+
ct=spec.get("ct", 500),
84
84
+
on=spec.get("on", True),
85
85
+
)
86
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
102
-
for phase in PHASES:
97
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
161
-
if external.mode in MODE_STATES:
162
162
-
return MODE_STATES[external.mode], f"{external.mode.value} by {external.updated_by}"
156
156
+
mode_states = get_mode_states()
157
157
+
if external.mode in mode_states:
158
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
283
-
for phase in PHASES:
279
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