Repository navigation
Expand file tree
/
Copy pathconfig.py
More file actions
193 lines (173 loc) · 9.03 KB
/
Copy pathconfig.py
File metadata and controls
193 lines (173 loc) · 9.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""Runtime configuration for PiClock, with environment-variable overrides.
Every field has a sensible default for the bedroom-clock appliance; override any
of them via PICLOCK_* environment variables (typically set in the systemd unit).
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass
from datetime import time
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
value = raw.strip().lower()
if value in _TRUE:
return True
if value in _FALSE:
return False
raise ValueError(f"{name}={raw!r} is not a valid boolean")
def _env_int(name: str, default: int, *, base: int = 10) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
return int(raw, base)
except ValueError:
raise ValueError(f"{name}={raw!r} is not a valid integer") from None
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name)
if raw is None:
return default
try:
return float(raw)
except ValueError:
raise ValueError(f"{name}={raw!r} is not a valid number") from None
def _env_time(name: str, default: time) -> time:
raw = os.environ.get(name)
if raw is None:
return default
try:
return time.fromisoformat(raw.strip())
except ValueError:
raise ValueError(f"{name}={raw!r} is not a valid HH:MM time") from None
@dataclass(frozen=True)
class Config:
"""Immutable runtime configuration."""
i2c_bus: int = 1
i2c_address: int = 0x70
brightness: int = 3 # 0-15 (HT16K33 dimming levels); low for a dark bedroom
# Levels the optional day/night schedule applies at its edges (see
# piclock/schedule.py + piclock/app.py): the display wakes at wake_brightness and
# dims to night_brightness. Same 0-15 scale as brightness; defaults are full
# for the evening and the dimmest lit level for night.
wake_brightness: int = 15
night_brightness: int = 0
# Optional day/night schedule, evaluated in-process by the main loop (no
# external timers). Off by default. The three edges must be in daily order
# (off_at < on_at < dim_at); the night window wraps past midnight. Times are
# local wall-clock (the Pi's timezone).
schedule: bool = False
off_at: time = time(8, 0) # blank the display (daytime)
on_at: time = time(17, 0) # show at wake_brightness (evening)
dim_at: time = time(20, 0) # drop to night_brightness (night)
hour24: bool = True
blink_colon: bool = False # steady colon by default — no movement for a dark bedroom
# The backpack's lower-left indicator dot shows NTP sync status: lit while
# the system clock is synced *and* syncs are still arriving, dark while it
# free-runs. It dims with the rest of the display; switch it off if even
# that is too much at night.
sync_dot: bool = True
# How recently (seconds) timesyncd must have completed a successful sync
# for the sync dot to stay lit. Default is ~2x timesyncd's max poll
# interval (34 min) plus margin, so one dropped poll can't flicker the
# dot. Checked via a stat() of timesyncd's stamp file — see
# piclock/timesync.py; on systems without the stamp the dot falls back to
# NTPSynchronized alone.
sync_fresh_seconds: float = 4500.0
# The backpack's upper-left indicator dot shows Wi-Fi link status: lit
# while the interface is up and associated, dark when the link drops (or
# the interface disappears entirely). Read from sysfs every tick — an
# in-process file read, no subprocess.
wifi_dot: bool = True
wifi_iface: str = "wlan0"
# Indicator polarity. Default (False): the dots are *status* lights — lit
# while everything is nominal (synced / link up). True: the dots are
# *trouble* lights — dark while nominal and lit only when something is
# wrong (sync stale or never achieved / link down), so a healthy clock
# shows nothing but the time. Each dot's enable flag still applies.
trouble_dots: bool = False
tick_seconds: float = 0.2 # loop granularity; colon blink derives from wall clock
backend: str = "ht16k33" # "ht16k33" for hardware, "null" for off-Pi dev/testing
# Unix datagram socket for one-off CLI commands (see piclock/ctl.py and
# scripts/piclockctl). Empty = disabled; the systemd unit sets
# /run/piclock/ctl.sock (backed by RuntimeDirectory=piclock).
ctl_socket: str = ""
# Optional HomeKit integration (v2). Off by default so the core appliance is
# unchanged and needs no extra deps. When on, the clock exposes a Lightbulb
# accessory: On/Off blanks/shows the time (to spare the 7-seg's lifespan) and
# Brightness maps to the HT16K33 dimming level. See requirements-homekit.txt.
homekit: bool = False
homekit_name: str = "Clock" # accessory name; "Hey Siri, turn off the Clock"
homekit_port: int = 51827
homekit_state_file: str = "homekit.state" # pairing keys; must be writable
#: Display backends make_display() knows how to build.
_BACKENDS = ("ht16k33", "null")
def __post_init__(self) -> None:
# Fail fast on misconfiguration rather than degrading silently: a
# non-positive tick would busy-loop the service, and an
# unrecognized backend would quietly fall through to hardware mode.
if not math.isfinite(self.tick_seconds) or self.tick_seconds <= 0:
raise ValueError(f"tick_seconds must be > 0, got {self.tick_seconds!r}")
if self.backend not in self._BACKENDS:
raise ValueError(
f"backend must be one of {self._BACKENDS}, got {self.backend!r}"
)
# The HT16K33 has exactly 16 dimming levels; reject anything else here
# rather than silently clamping a typo'd level downstream.
for name in ("brightness", "wake_brightness", "night_brightness"):
level = getattr(self, name)
if not 0 <= level <= 15:
raise ValueError(f"{name} must be 0-15, got {level!r}")
# A non-positive freshness window would permanently darken the sync
# dot, and an empty interface name would permanently darken the wifi
# dot — both look like outages, so reject them instead.
if not math.isfinite(self.sync_fresh_seconds) or self.sync_fresh_seconds <= 0:
raise ValueError(
f"sync_fresh_seconds must be > 0, got {self.sync_fresh_seconds!r}"
)
if self.wifi_dot and not self.wifi_iface:
raise ValueError("wifi_iface must be non-empty when wifi_dot is on")
# The schedule logic (piclock/schedule.py) assumes the edges are in daily
# order; a misordered schedule would silently pick wrong phases.
for name in ("off_at", "on_at", "dim_at"):
if getattr(self, name).tzinfo is not None:
raise ValueError(f"{name} must be a local time without a UTC offset")
if not (self.off_at < self.on_at < self.dim_at):
raise ValueError(
"schedule times must be in daily order off_at < on_at < dim_at, "
f"got off_at={self.off_at}, on_at={self.on_at}, dim_at={self.dim_at}"
)
@classmethod
def from_env(cls) -> "Config":
return cls(
i2c_bus=_env_int("PICLOCK_I2C_BUS", cls.i2c_bus),
i2c_address=_env_int("PICLOCK_I2C_ADDRESS", cls.i2c_address, base=0),
brightness=_env_int("PICLOCK_BRIGHTNESS", cls.brightness),
wake_brightness=_env_int("PICLOCK_WAKE_BRIGHTNESS", cls.wake_brightness),
night_brightness=_env_int("PICLOCK_NIGHT_BRIGHTNESS", cls.night_brightness),
schedule=_env_bool("PICLOCK_SCHEDULE", cls.schedule),
off_at=_env_time("PICLOCK_OFF_AT", cls.off_at),
on_at=_env_time("PICLOCK_ON_AT", cls.on_at),
dim_at=_env_time("PICLOCK_DIM_AT", cls.dim_at),
hour24=_env_bool("PICLOCK_24H", cls.hour24),
blink_colon=_env_bool("PICLOCK_BLINK", cls.blink_colon),
sync_dot=_env_bool("PICLOCK_SYNC_DOT", cls.sync_dot),
sync_fresh_seconds=_env_float(
"PICLOCK_SYNC_FRESH", cls.sync_fresh_seconds
),
wifi_dot=_env_bool("PICLOCK_WIFI_DOT", cls.wifi_dot),
wifi_iface=os.environ.get("PICLOCK_WIFI_IFACE", cls.wifi_iface).strip(),
trouble_dots=_env_bool("PICLOCK_TROUBLE_DOTS", cls.trouble_dots),
tick_seconds=_env_float("PICLOCK_TICK", cls.tick_seconds),
backend=os.environ.get("PICLOCK_DISPLAY", cls.backend).strip().lower(),
ctl_socket=os.environ.get("PICLOCK_CTL_SOCKET", cls.ctl_socket),
homekit=_env_bool("PICLOCK_HOMEKIT", cls.homekit),
homekit_name=os.environ.get("PICLOCK_HOMEKIT_NAME", cls.homekit_name),
homekit_port=_env_int("PICLOCK_HOMEKIT_PORT", cls.homekit_port),
homekit_state_file=os.environ.get(
"PICLOCK_HOMEKIT_STATE", cls.homekit_state_file
),
)