Repository navigation
Expand file tree
/
Copy pathhomekit.py
More file actions
137 lines (106 loc) · 5.33 KB
/
Copy pathhomekit.py
File metadata and controls
137 lines (106 loc) · 5.33 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
"""Optional HomeKit accessory exposing the clock as a Lightbulb.
Lets you say "Hey Siri, turn off the Clock" to blank the 7-segment display
(sparing its lifespan) and "...turn on the Clock" to bring it back, plus
brightness control. The accessory's On characteristic drives DisplayControl.
enabled; Brightness maps to the HT16K33 dimming level.
pyhap is imported lazily inside start_homekit so the core app runs without the
HomeKit dependencies installed. The driver runs in a daemon thread; the app
loop polls DisplayControl each tick (see app.run).
zeroconf note: requirements-homekit.txt selects the zeroconf version by CPU
architecture (PEP 508 markers) — 0.39.4 on armv6 (Pi Zero W, the last
pure-Python release, since newer versions have no armv6 wheel) and the current
release elsewhere (Pi Zero 2 W and up). HAP-python 5.0.0 works against both
(driver + Lightbulb verified on armv6).
"""
from __future__ import annotations
import logging
import os
import threading
from .config import Config
from .control import DisplayControl
log = logging.getLogger("piclock")
# HomeKit Brightness is a 0-100 percentage; the HT16K33 has 16 dimming levels.
_MAX_LEVEL = 15
def level_to_pct(level: int) -> int:
"""HT16K33 level (0-15) -> HomeKit brightness percentage (1-100).
Floored at 1%: a lit display reports at least 1% so the Home app never shows
0%, which it treats as off (dragging the slider to 0 sends On=false). The
dimmest level (0) is still lit, so it must report 1%, not 0%.
"""
level = max(0, min(_MAX_LEVEL, int(level)))
return max(1, round(level * 100 / _MAX_LEVEL))
def pct_to_level(pct: int) -> int:
"""HomeKit brightness percentage (0-100) -> HT16K33 level (0-15)."""
pct = max(0, min(100, int(pct)))
return round(pct * _MAX_LEVEL / 100)
class HomeKit:
"""Handle for a running accessory: shut it down, and push external state.
``notify_state`` mirrors a change made outside HomeKit (the day/night
schedule) back into the Home app, so its on/off + brightness tile stays in
sync rather than showing only what Siri last set.
"""
def __init__(self, driver, accessory) -> None:
self._driver = driver
self._accessory = accessory
def notify_state(self, enabled: bool, brightness: int) -> None:
try:
self._accessory.update_state(enabled, brightness)
except Exception:
# Notifications are optional too: a broken HomeKit connection must
# never take down timekeeping or prevent a local control command.
log.exception("HomeKit state notification failed")
def stop(self) -> None:
self._driver.stop()
def start_homekit(config: Config, control: DisplayControl) -> HomeKit:
"""Build the Lightbulb accessory and run the pyhap driver in a daemon thread.
Returns a HomeKit handle (``stop()`` on app exit, ``notify_state()`` to
reflect schedule-driven changes back to the Home app).
"""
from pyhap.accessory import Accessory
from pyhap.accessory_driver import AccessoryDriver
from pyhap.const import CATEGORY_LIGHTBULB
class ClockLight(Accessory):
category = CATEGORY_LIGHTBULB
def __init__(self, driver, name: str) -> None:
super().__init__(driver, name)
serv = self.add_preload_service("Lightbulb", chars=["On", "Brightness"])
self._on = serv.configure_char(
"On", value=control.enabled, setter_callback=self._set_on
)
self._brightness = serv.configure_char(
"Brightness",
value=level_to_pct(control.brightness),
setter_callback=self._set_brightness,
)
def _set_on(self, value) -> None:
control.enabled = bool(value)
log.info("HomeKit: display %s", "on" if value else "off")
def _set_brightness(self, value) -> None:
control.brightness = pct_to_level(value)
log.info("HomeKit: brightness %s%% -> level %s", value, control.brightness)
def update_state(self, enabled: bool, brightness: int) -> None:
# Reflect an externally-driven change (the schedule) into the Home
# app. set_value notifies subscribed controllers; it does NOT invoke
# the setter_callbacks, so there is no feedback loop, and
# AccessoryDriver.publish is thread-safe (it hops to the event loop),
# so this is safe to call from the main loop thread.
self._on.set_value(enabled)
self._brightness.set_value(level_to_pct(brightness))
# The pairing-state file must live somewhere writable (under systemd's
# StateDirectory in prod); ensure its parent exists.
state_dir = os.path.dirname(os.path.abspath(config.homekit_state_file))
os.makedirs(state_dir, exist_ok=True)
driver = AccessoryDriver(
port=config.homekit_port, persist_file=config.homekit_state_file
)
accessory = ClockLight(driver, config.homekit_name)
driver.add_accessory(accessory=accessory)
thread = threading.Thread(target=driver.start, name="homekit", daemon=True)
thread.start()
log.info(
"HomeKit accessory %r listening on port %s; pairing PIN %s",
config.homekit_name,
config.homekit_port,
driver.state.pincode.decode() if driver.state.pincode else "(see Home app)",
)
return HomeKit(driver, accessory)