zencontrol-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +0 -0
- examples/mqtt_bridge.py +1142 -0
- zencontrol/__init__.py +108 -0
- zencontrol/api/__init__.py +30 -0
- zencontrol/api/models.py +273 -0
- zencontrol/api/protocol.py +1700 -0
- zencontrol/api/types.py +203 -0
- zencontrol/exceptions.py +30 -0
- zencontrol/interface/__init__.py +33 -0
- zencontrol/interface/interface.py +1579 -0
- zencontrol/io/__init__.py +24 -0
- zencontrol/io/command.py +387 -0
- zencontrol/io/event.py +316 -0
- zencontrol/py.typed +0 -0
- zencontrol/utils.py +67 -0
- zencontrol_python-0.1.0.dist-info/METADATA +79 -0
- zencontrol_python-0.1.0.dist-info/RECORD +21 -0
- zencontrol_python-0.1.0.dist-info/WHEEL +5 -0
- zencontrol_python-0.1.0.dist-info/entry_points.txt +2 -0
- zencontrol_python-0.1.0.dist-info/licenses/LICENSE +504 -0
- zencontrol_python-0.1.0.dist-info/top_level.txt +2 -0
examples/mqtt_bridge.py
ADDED
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ipaddress
|
|
3
|
+
import time
|
|
4
|
+
import json
|
|
5
|
+
import yaml
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
import zencontrol
|
|
10
|
+
from zencontrol import ZenController, ZenProtocol, ZenClient, ZenColour, ZenColourType, ZenProfile, ZenLight, ZenGroup, ZenButton, ZenMotionSensor, ZenSystemVariable, ZenTimeoutError, ZenAddressType
|
|
11
|
+
from zencontrol.api.types import Const as ApiConst
|
|
12
|
+
import aiomqtt
|
|
13
|
+
from colorama import Fore, Back, Style
|
|
14
|
+
import logging
|
|
15
|
+
from logging.handlers import RotatingFileHandler
|
|
16
|
+
import math
|
|
17
|
+
import pickle
|
|
18
|
+
import traceback
|
|
19
|
+
|
|
20
|
+
class RateLimiter:
|
|
21
|
+
"""Rate limiter to control concurrent coroutine execution"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_concurrent: int = 5, delay_between_batches: float = 0.1):
|
|
24
|
+
self.semaphore = asyncio.Semaphore(max_concurrent)
|
|
25
|
+
self.delay_between_batches = delay_between_batches
|
|
26
|
+
self.last_batch_time = 0
|
|
27
|
+
|
|
28
|
+
async def execute(self, coro):
|
|
29
|
+
"""Execute a coroutine with rate limiting"""
|
|
30
|
+
# Ensure minimum delay between batches
|
|
31
|
+
current_time = time.time()
|
|
32
|
+
time_since_last_batch = current_time - self.last_batch_time
|
|
33
|
+
if time_since_last_batch < self.delay_between_batches:
|
|
34
|
+
await asyncio.sleep(self.delay_between_batches - time_since_last_batch)
|
|
35
|
+
|
|
36
|
+
async with self.semaphore:
|
|
37
|
+
self.last_batch_time = time.time()
|
|
38
|
+
return await coro
|
|
39
|
+
|
|
40
|
+
async def execute_batch(self, coros, batch_size: int = None):
|
|
41
|
+
"""Execute multiple coroutines in controlled batches"""
|
|
42
|
+
if batch_size is None:
|
|
43
|
+
batch_size = self.semaphore._value # Use semaphore limit as batch size
|
|
44
|
+
|
|
45
|
+
results = []
|
|
46
|
+
for i in range(0, len(coros), batch_size):
|
|
47
|
+
batch = coros[i:i + batch_size]
|
|
48
|
+
batch_results = await asyncio.gather(*[self.execute(coro) for coro in batch])
|
|
49
|
+
results.extend(batch_results)
|
|
50
|
+
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
class Const:
|
|
54
|
+
|
|
55
|
+
STARTUP_POLL_DELAY = 10
|
|
56
|
+
STARTUP_RETRY_MIN_DELAY = 5
|
|
57
|
+
STARTUP_RETRY_MAX_DELAY = 60
|
|
58
|
+
|
|
59
|
+
# MQTT settings
|
|
60
|
+
MQTT_RECONNECT_MIN_DELAY = 1
|
|
61
|
+
MQTT_RECONNECT_MAX_DELAY = 10
|
|
62
|
+
MQTT_SERVICE_PREFIX = "zencontrol-python"
|
|
63
|
+
|
|
64
|
+
# Logging
|
|
65
|
+
LOG_FILE = 'mqtt.log'
|
|
66
|
+
DEBUG_FILE = 'mqtt.debug.log'
|
|
67
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB
|
|
68
|
+
LOG_BACKUP_COUNT = 5
|
|
69
|
+
|
|
70
|
+
# Logarithmic constants
|
|
71
|
+
|
|
72
|
+
# Based on curves seen in DALI documentation
|
|
73
|
+
# LOG_A = 44.74
|
|
74
|
+
# LOG_B = 37.77
|
|
75
|
+
|
|
76
|
+
# Attempt at a better fit, less extreme
|
|
77
|
+
LOG_A = -59.53
|
|
78
|
+
LOG_B = 56.58
|
|
79
|
+
|
|
80
|
+
# Default hold time for motion sensors, in seconds
|
|
81
|
+
DEFAULT_HOLD_TIME = 15
|
|
82
|
+
|
|
83
|
+
# Default long press time for buttons, in msec
|
|
84
|
+
DEFAULT_LONG_PRESS_TIME = 1000
|
|
85
|
+
|
|
86
|
+
class ZenMQTTBridge:
|
|
87
|
+
"""Bridge between Zen lighting control system and MQTT/Home Assistant.
|
|
88
|
+
|
|
89
|
+
Handles bidirectional communication between Zen lighting controllers and
|
|
90
|
+
Home Assistant via MQTT, including auto-discovery and state management.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# ================================
|
|
94
|
+
# INIT & RUN
|
|
95
|
+
# ================================
|
|
96
|
+
|
|
97
|
+
def __init__(self, config_path: str | None = None) -> None:
|
|
98
|
+
self.example_dir = Path(__file__).resolve().parent
|
|
99
|
+
config_path = config_path or str(self.example_dir / "config.yaml")
|
|
100
|
+
self.config: dict[str, Any]
|
|
101
|
+
with open(config_path) as f:
|
|
102
|
+
self.config = yaml.safe_load(f)
|
|
103
|
+
|
|
104
|
+
self.logger: logging.Logger
|
|
105
|
+
self.discovery_prefix: str
|
|
106
|
+
self.control: list[ZenController] = []
|
|
107
|
+
self.zen: zencontrol.ZenControl
|
|
108
|
+
self.mqttc: aiomqtt.Client
|
|
109
|
+
self.setup_started: bool = False
|
|
110
|
+
self.setup_complete: bool = False
|
|
111
|
+
self.system_variables: list[ZenSystemVariable] = []
|
|
112
|
+
self.sv_config: list[dict] = []
|
|
113
|
+
|
|
114
|
+
self.config_topics_to_delete: list[str] = [] # List of topics to delete after completing setup
|
|
115
|
+
self.topic_object: dict[str, Any] = {} # Map of topics to objects
|
|
116
|
+
self._zen_online = False
|
|
117
|
+
|
|
118
|
+
# Rate limiter for controlling concurrent operations
|
|
119
|
+
self.rate_limiter = RateLimiter(max_concurrent=5, delay_between_batches=0.1)
|
|
120
|
+
|
|
121
|
+
self.global_config: dict[str, Any] = {
|
|
122
|
+
"origin": {
|
|
123
|
+
"name": "zencontrol-python",
|
|
124
|
+
"sw": zencontrol.__version__,
|
|
125
|
+
"url": "https://github.com/sjwright/zencontrol-python"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async def run(self) -> None:
|
|
130
|
+
self.setup_logging()
|
|
131
|
+
self.setup_config()
|
|
132
|
+
self.logger.info("==================================== Starting ZenMQTTBridge ====================================")
|
|
133
|
+
await self.setup_zen()
|
|
134
|
+
await self.setup_mqtt()
|
|
135
|
+
|
|
136
|
+
# Start MQTT first so HA can see availability while Zen controllers come up
|
|
137
|
+
self.mqtt_task = asyncio.create_task(self._mqtt_message_handler())
|
|
138
|
+
await asyncio.sleep(0.5)
|
|
139
|
+
|
|
140
|
+
await self._wait_controllers_ready()
|
|
141
|
+
|
|
142
|
+
# Generate config topics
|
|
143
|
+
self.setup_started = True
|
|
144
|
+
await self.setup_profiles()
|
|
145
|
+
await self.setup_lights()
|
|
146
|
+
await self.setup_groups()
|
|
147
|
+
await self.setup_buttons()
|
|
148
|
+
await self.setup_motion_sensors()
|
|
149
|
+
await self.setup_system_variables()
|
|
150
|
+
await self.delete_retained_topics()
|
|
151
|
+
self.setup_complete = True
|
|
152
|
+
|
|
153
|
+
# Begin listening for zen events (reconnects automatically on loss)
|
|
154
|
+
await self.zen.start()
|
|
155
|
+
|
|
156
|
+
with open(self.example_dir / "cache.pkl", "wb") as f:
|
|
157
|
+
pickle.dump(self.zen.cache, f)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
controllers:
|
|
163
|
+
- id: 1
|
|
164
|
+
name: zen1
|
|
165
|
+
label: Zencontrol 1
|
|
166
|
+
groups:
|
|
167
|
+
- id: 0
|
|
168
|
+
name: All Lights
|
|
169
|
+
hide_group: True
|
|
170
|
+
hide_members: True
|
|
171
|
+
- id: 1
|
|
172
|
+
name: Ground
|
|
173
|
+
lights:
|
|
174
|
+
- ecg: 14
|
|
175
|
+
name: Lounge NW
|
|
176
|
+
room: Downstairs
|
|
177
|
+
groups:
|
|
178
|
+
- 15
|
|
179
|
+
- ecg: 21
|
|
180
|
+
name: Guest NE
|
|
181
|
+
room: Guest Bedroom
|
|
182
|
+
groups:
|
|
183
|
+
- 15
|
|
184
|
+
- ecg: 42
|
|
185
|
+
name: Garage
|
|
186
|
+
override_name: Garage Downlights
|
|
187
|
+
room: Garage
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
clist = []
|
|
191
|
+
for c in sorted(self.control, key=lambda x: x.id):
|
|
192
|
+
c: ZenController
|
|
193
|
+
glist = []
|
|
194
|
+
llist = []
|
|
195
|
+
for g in sorted(c.groups, key=lambda x: x.address.number):
|
|
196
|
+
g: ZenGroup
|
|
197
|
+
glist.append({
|
|
198
|
+
"id": g.address.number,
|
|
199
|
+
"label": g.label,
|
|
200
|
+
})
|
|
201
|
+
for l in sorted(c.lights, key=lambda x: x.address.number):
|
|
202
|
+
l: ZenLight
|
|
203
|
+
llist.append({
|
|
204
|
+
"ecg": l.address.number,
|
|
205
|
+
"label": l.label,
|
|
206
|
+
})
|
|
207
|
+
clist.append({
|
|
208
|
+
"id": c.id,
|
|
209
|
+
"name": c.name,
|
|
210
|
+
"label": c.label,
|
|
211
|
+
"groups": glist,
|
|
212
|
+
"lights": llist
|
|
213
|
+
})
|
|
214
|
+
# Dump to yaml file
|
|
215
|
+
with open(self.example_dir / "dump.yaml", "w") as f:
|
|
216
|
+
yaml.dump(clist, f, sort_keys=False)
|
|
217
|
+
|
|
218
|
+
while True:
|
|
219
|
+
await asyncio.sleep(1)
|
|
220
|
+
|
|
221
|
+
async def _wait_controllers_ready(self) -> None:
|
|
222
|
+
"""Poll controllers until ready; retry with backoff instead of aborting."""
|
|
223
|
+
delay = Const.STARTUP_RETRY_MIN_DELAY
|
|
224
|
+
attempt = 0
|
|
225
|
+
while True:
|
|
226
|
+
attempt += 1
|
|
227
|
+
try:
|
|
228
|
+
for ctrl in self.control:
|
|
229
|
+
self.logger.info(
|
|
230
|
+
f"Connecting to Zen controller {ctrl.name} on {ctrl.host}:{ctrl.port}..."
|
|
231
|
+
)
|
|
232
|
+
while True:
|
|
233
|
+
ready = await ctrl.is_controller_ready()
|
|
234
|
+
if ready is None:
|
|
235
|
+
raise ZenTimeoutError(
|
|
236
|
+
f"cannot query startup state for {ctrl.name}"
|
|
237
|
+
)
|
|
238
|
+
if ready:
|
|
239
|
+
break
|
|
240
|
+
self.logger.info(f"Controller {ctrl.label} still starting up...")
|
|
241
|
+
await asyncio.sleep(Const.STARTUP_POLL_DELAY)
|
|
242
|
+
await ctrl.interview()
|
|
243
|
+
self.logger.info("All Zen controllers ready")
|
|
244
|
+
return
|
|
245
|
+
except (ZenTimeoutError, OSError, ConnectionError) as e:
|
|
246
|
+
self.logger.warning(
|
|
247
|
+
"Zen controllers not ready (attempt %d): %s; retrying in %ds",
|
|
248
|
+
attempt,
|
|
249
|
+
e,
|
|
250
|
+
delay,
|
|
251
|
+
)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self.logger.warning(
|
|
254
|
+
"Error reaching Zen controllers (attempt %d): %s; retrying in %ds",
|
|
255
|
+
attempt,
|
|
256
|
+
e,
|
|
257
|
+
delay,
|
|
258
|
+
)
|
|
259
|
+
self._zen_online = False
|
|
260
|
+
await self._publish_zen_availability()
|
|
261
|
+
for ctrl in self.control:
|
|
262
|
+
ctrl.refresh_ip()
|
|
263
|
+
client = ctrl.client
|
|
264
|
+
ctrl.client = None
|
|
265
|
+
if client is not None:
|
|
266
|
+
try:
|
|
267
|
+
await client.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
await asyncio.sleep(delay)
|
|
271
|
+
delay = min(delay * 2, Const.STARTUP_RETRY_MAX_DELAY)
|
|
272
|
+
|
|
273
|
+
async def stop(self) -> None:
|
|
274
|
+
"""Clean shutdown of the bridge"""
|
|
275
|
+
if hasattr(self, "zen"):
|
|
276
|
+
await self.zen.aclose()
|
|
277
|
+
if hasattr(self, 'mqtt_task'):
|
|
278
|
+
self.mqtt_task.cancel()
|
|
279
|
+
try:
|
|
280
|
+
await self.mqtt_task
|
|
281
|
+
except asyncio.CancelledError:
|
|
282
|
+
pass
|
|
283
|
+
# MQTT connection is automatically closed by the context manager
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ================================
|
|
287
|
+
# CONFIG
|
|
288
|
+
# ================================
|
|
289
|
+
|
|
290
|
+
def setup_config(self) -> None:
|
|
291
|
+
try:
|
|
292
|
+
|
|
293
|
+
# Improved config validation
|
|
294
|
+
required_sections = ['homeassistant', 'mqtt', 'zencontrol']
|
|
295
|
+
missing = [s for s in required_sections if s not in self.config]
|
|
296
|
+
if missing:
|
|
297
|
+
raise ValueError(f"Missing required config sections: {', '.join(missing)}")
|
|
298
|
+
|
|
299
|
+
# Validate MQTT config
|
|
300
|
+
mqtt_required = ['host', 'port', 'user', 'password', 'keepalive']
|
|
301
|
+
missing = [f for f in mqtt_required if f not in self.config['mqtt']]
|
|
302
|
+
if missing:
|
|
303
|
+
raise ValueError(f"Missing MQTT config fields: {', '.join(missing)}")
|
|
304
|
+
|
|
305
|
+
# Validate Zencontrol config
|
|
306
|
+
if not isinstance(self.config['zencontrol'], list):
|
|
307
|
+
raise ValueError("zencontrol config must be a list")
|
|
308
|
+
|
|
309
|
+
zencontrol_required = ['id', 'name', 'label', 'mac', 'host', 'port']
|
|
310
|
+
for i, config in enumerate(self.config['zencontrol']):
|
|
311
|
+
|
|
312
|
+
# Check for required fields
|
|
313
|
+
missing = [f for f in zencontrol_required if f not in config]
|
|
314
|
+
if missing:
|
|
315
|
+
raise ValueError(f"Missing Zencontrol config fields in entry {i}: {', '.join(missing)}")
|
|
316
|
+
|
|
317
|
+
# Validate name format (alphanumeric only)
|
|
318
|
+
name = config['name']
|
|
319
|
+
if not re.match(r'^[A-Za-z0-9]+$', name):
|
|
320
|
+
raise ValueError(f"Invalid name format in Zencontrol config {i}: {name}. Use only letters and numbers.")
|
|
321
|
+
|
|
322
|
+
# Validate MAC address format (xx:xx:xx:xx:xx:xx)
|
|
323
|
+
mac = config['mac']
|
|
324
|
+
if not re.match(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$', mac):
|
|
325
|
+
raise ValueError(f"Invalid MAC address format in Zencontrol config {i}: {mac}")
|
|
326
|
+
|
|
327
|
+
# Validate IP address
|
|
328
|
+
host = config['host']
|
|
329
|
+
try:
|
|
330
|
+
ipaddress.ip_address(host)
|
|
331
|
+
except ValueError:
|
|
332
|
+
raise ValueError(f"Invalid IP address in Zencontrol config {i}: {host}")
|
|
333
|
+
|
|
334
|
+
# Validate port number (1-65535)
|
|
335
|
+
port = config['port']
|
|
336
|
+
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
337
|
+
raise ValueError(f"Invalid port number in Zencontrol config {i}: {port}")
|
|
338
|
+
|
|
339
|
+
except ValueError as e:
|
|
340
|
+
self.logger.error(f"Failed to load config file: {e}")
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
# ================================
|
|
344
|
+
# LOGGING
|
|
345
|
+
# ================================
|
|
346
|
+
|
|
347
|
+
def setup_logging(self) -> None:
|
|
348
|
+
"""Configure logging with both file and console handlers."""
|
|
349
|
+
self.logger = logging.getLogger('ZenMQTTBridge')
|
|
350
|
+
self.logger.setLevel(logging.DEBUG)
|
|
351
|
+
|
|
352
|
+
# File handler
|
|
353
|
+
file_handler = RotatingFileHandler(
|
|
354
|
+
Const.LOG_FILE,
|
|
355
|
+
maxBytes=Const.LOG_MAX_BYTES,
|
|
356
|
+
backupCount=Const.LOG_BACKUP_COUNT
|
|
357
|
+
)
|
|
358
|
+
# Exclude debug messages
|
|
359
|
+
file_handler.addFilter(lambda record: record.levelno != logging.DEBUG)
|
|
360
|
+
file_handler.setFormatter(logging.Formatter(fmt="%(asctime)s\t%(levelname)s\t%(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
|
361
|
+
self.logger.addHandler(file_handler)
|
|
362
|
+
|
|
363
|
+
# Debug only handler
|
|
364
|
+
debug_handler = logging.FileHandler(Const.DEBUG_FILE)
|
|
365
|
+
debug_handler.setLevel(logging.DEBUG)
|
|
366
|
+
debug_handler.setFormatter(logging.Formatter(fmt="%(asctime)s\t%(levelname)s\t%(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
|
367
|
+
self.logger.addHandler(debug_handler)
|
|
368
|
+
|
|
369
|
+
# Console handler
|
|
370
|
+
console_handler = logging.StreamHandler()
|
|
371
|
+
console_handler.setLevel(logging.INFO)
|
|
372
|
+
console_handler.setFormatter(logging.Formatter(
|
|
373
|
+
'%(asctime)s.%(msecs)03d %(levelname)s: %(message)s', datefmt='%H:%M:%S'
|
|
374
|
+
))
|
|
375
|
+
self.logger.addHandler(console_handler)
|
|
376
|
+
|
|
377
|
+
# ================================
|
|
378
|
+
# ZEN
|
|
379
|
+
# ================================
|
|
380
|
+
|
|
381
|
+
def _load_cache(self) -> dict:
|
|
382
|
+
"""Load and validate persisted protocol query cache, or return empty dict."""
|
|
383
|
+
cache_path = self.example_dir / "cache.pkl"
|
|
384
|
+
try:
|
|
385
|
+
with open(cache_path, "rb") as infile:
|
|
386
|
+
raw = pickle.load(infile)
|
|
387
|
+
except FileNotFoundError:
|
|
388
|
+
return {}
|
|
389
|
+
except (OSError, pickle.UnpicklingError, EOFError, AttributeError) as e:
|
|
390
|
+
self.logger.warning(f"Ignoring corrupt cache {cache_path}: {e}")
|
|
391
|
+
return {}
|
|
392
|
+
|
|
393
|
+
if not isinstance(raw, dict):
|
|
394
|
+
self.logger.warning(f"Ignoring invalid cache {cache_path}: expected dict, got {type(raw).__name__}")
|
|
395
|
+
return {}
|
|
396
|
+
|
|
397
|
+
cache: dict = {}
|
|
398
|
+
now = time.time()
|
|
399
|
+
for key, entry in raw.items():
|
|
400
|
+
if not isinstance(key, bytes) or not isinstance(entry, dict):
|
|
401
|
+
continue
|
|
402
|
+
data = entry.get("d")
|
|
403
|
+
resp_type = entry.get("c")
|
|
404
|
+
timestamp = entry.get("t")
|
|
405
|
+
if not isinstance(data, bytes) or not isinstance(resp_type, int) or not isinstance(timestamp, (int, float)):
|
|
406
|
+
continue
|
|
407
|
+
if now - timestamp >= ApiConst.CACHE_TIMEOUT:
|
|
408
|
+
continue
|
|
409
|
+
cache[key] = {"d": data, "c": resp_type, "t": timestamp}
|
|
410
|
+
|
|
411
|
+
skipped = len(raw) - len(cache)
|
|
412
|
+
if skipped:
|
|
413
|
+
self.logger.info(f"Loaded {len(cache)} cache entries from {cache_path} ({skipped} invalid or expired)")
|
|
414
|
+
elif cache:
|
|
415
|
+
self.logger.info(f"Loaded {len(cache)} cache entries from {cache_path}")
|
|
416
|
+
return cache
|
|
417
|
+
|
|
418
|
+
async def setup_zen(self) -> None:
|
|
419
|
+
try:
|
|
420
|
+
cache = self._load_cache()
|
|
421
|
+
self.zen: zencontrol.ZenControl = zencontrol.ZenControl(logger=self.logger, print_traffic=True, cache=cache)
|
|
422
|
+
self.zen.on_connect = self._zen_on_connect
|
|
423
|
+
self.zen.on_disconnect = self._zen_on_disconnect
|
|
424
|
+
self.zen.profile_change = self._zen_profile_change
|
|
425
|
+
self.zen.group_change = self._zen_group_change
|
|
426
|
+
self.zen.light_change = self._zen_light_change
|
|
427
|
+
self.zen.button_press = self._zen_button_press
|
|
428
|
+
self.zen.button_long_press = self._zen_button_long_press
|
|
429
|
+
self.zen.motion_event = self._zen_motion_event
|
|
430
|
+
self.zen.system_variable_change = self._zen_system_variable_change
|
|
431
|
+
for config in self.config['zencontrol']:
|
|
432
|
+
ctrl = self.zen.add_controller(
|
|
433
|
+
id=config['id'],
|
|
434
|
+
name=config['name'],
|
|
435
|
+
label=config['label'],
|
|
436
|
+
host=config['host'],
|
|
437
|
+
port=config['port'],
|
|
438
|
+
mac=config['mac']
|
|
439
|
+
)
|
|
440
|
+
# Add to my own internal list
|
|
441
|
+
self.control.append(ctrl)
|
|
442
|
+
# Add system variables to list
|
|
443
|
+
for sv in config.get('system_variables', []):
|
|
444
|
+
sv['controller'] = ctrl
|
|
445
|
+
self.sv_config.append(sv)
|
|
446
|
+
except Exception as e:
|
|
447
|
+
self.logger.error(f"Failed to initialize Zen: {e}")
|
|
448
|
+
raise
|
|
449
|
+
|
|
450
|
+
async def _zen_on_connect(self) -> None:
|
|
451
|
+
self.logger.info("Connected to Zen controllers")
|
|
452
|
+
self._zen_online = True
|
|
453
|
+
await self._publish_zen_availability()
|
|
454
|
+
|
|
455
|
+
async def _zen_on_disconnect(self) -> None:
|
|
456
|
+
self.logger.info("Disconnected from Zen controllers")
|
|
457
|
+
self._zen_online = False
|
|
458
|
+
await self._publish_zen_availability()
|
|
459
|
+
|
|
460
|
+
async def _publish_zen_availability(self) -> None:
|
|
461
|
+
"""Publish retained availability reflecting Zen event-listener state."""
|
|
462
|
+
mqttc = getattr(self, "mqttc", None)
|
|
463
|
+
if mqttc is None:
|
|
464
|
+
return
|
|
465
|
+
payload = "online" if self._zen_online else "offline"
|
|
466
|
+
for ctrl in self.control:
|
|
467
|
+
try:
|
|
468
|
+
await mqttc.publish(
|
|
469
|
+
f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
|
|
470
|
+
payload,
|
|
471
|
+
retain=True,
|
|
472
|
+
)
|
|
473
|
+
except Exception as e:
|
|
474
|
+
self.logger.debug(f"Failed to publish availability for {ctrl.name}: {e}")
|
|
475
|
+
|
|
476
|
+
# ================================
|
|
477
|
+
# ZEN PUBLISHING
|
|
478
|
+
# ================================
|
|
479
|
+
|
|
480
|
+
def kelvin_to_mireds(self, kelvin: int) -> int:
|
|
481
|
+
"""Convert color temperature in Kelvin to Mireds (micro reciprocal degrees)"""
|
|
482
|
+
if kelvin <= 0: return 0
|
|
483
|
+
return round(1000000 / kelvin)
|
|
484
|
+
|
|
485
|
+
def mireds_to_kelvin(self, mireds: int) -> int:
|
|
486
|
+
"""Convert Mireds (micro reciprocal degrees) to color temperature in Kelvin"""
|
|
487
|
+
if mireds <= 0: return 0
|
|
488
|
+
return round(1000000 / mireds)
|
|
489
|
+
|
|
490
|
+
def arc_to_brightness(self, value):
|
|
491
|
+
"""Convert logarithmic DALI ARC value (0-254) to linear brightness (0-255)"""
|
|
492
|
+
if value <= 0: return 0
|
|
493
|
+
X = round(math.exp((value - Const.LOG_A) / Const.LOG_B))
|
|
494
|
+
# print(f"arc_to_brightness({value}) = {X}")
|
|
495
|
+
return X
|
|
496
|
+
|
|
497
|
+
def brightness_to_arc(self, value):
|
|
498
|
+
"""Convert linear brightness (0-255) to logarithmic DALI ARC value (0-254)"""
|
|
499
|
+
if value <= 0: return 0
|
|
500
|
+
X = round(Const.LOG_A + Const.LOG_B * math.log(value))
|
|
501
|
+
# print(f"brightness_to_arc({value}) = {X}")
|
|
502
|
+
return X
|
|
503
|
+
|
|
504
|
+
# ================================
|
|
505
|
+
# MQTT
|
|
506
|
+
# ================================
|
|
507
|
+
|
|
508
|
+
async def setup_mqtt(self) -> None:
|
|
509
|
+
"""Set up MQTT configuration. Connection is handled by the message handler."""
|
|
510
|
+
try:
|
|
511
|
+
self.discovery_prefix = self.config['homeassistant']['discovery_prefix']
|
|
512
|
+
self.logger.info("MQTT configuration set up successfully")
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
self.logger.error(f"Failed to set up MQTT configuration: {e}")
|
|
516
|
+
raise
|
|
517
|
+
|
|
518
|
+
async def _mqtt_message_handler(self) -> None:
|
|
519
|
+
"""Handle incoming MQTT messages with automatic reconnection per aiomqtt docs."""
|
|
520
|
+
interval = Const.MQTT_RECONNECT_MIN_DELAY
|
|
521
|
+
|
|
522
|
+
while True:
|
|
523
|
+
try:
|
|
524
|
+
# Create a new client for each connection attempt
|
|
525
|
+
mqtt_config = self.config["mqtt"]
|
|
526
|
+
|
|
527
|
+
# Create will messages for availability
|
|
528
|
+
will_messages = []
|
|
529
|
+
for ctrl in self.control:
|
|
530
|
+
will_messages.append(aiomqtt.Will(
|
|
531
|
+
topic=f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
|
|
532
|
+
payload="offline",
|
|
533
|
+
retain=True
|
|
534
|
+
))
|
|
535
|
+
|
|
536
|
+
# Create client with will messages
|
|
537
|
+
if will_messages:
|
|
538
|
+
client = aiomqtt.Client(
|
|
539
|
+
hostname=mqtt_config["host"],
|
|
540
|
+
port=mqtt_config["port"],
|
|
541
|
+
username=mqtt_config["user"],
|
|
542
|
+
password=mqtt_config["password"],
|
|
543
|
+
keepalive=mqtt_config["keepalive"],
|
|
544
|
+
will=will_messages[0] # aiomqtt only supports one will message
|
|
545
|
+
)
|
|
546
|
+
else:
|
|
547
|
+
client = aiomqtt.Client(
|
|
548
|
+
hostname=mqtt_config["host"],
|
|
549
|
+
port=mqtt_config["port"],
|
|
550
|
+
username=mqtt_config["user"],
|
|
551
|
+
password=mqtt_config["password"],
|
|
552
|
+
keepalive=mqtt_config["keepalive"]
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Use the client context manager for automatic connection handling
|
|
556
|
+
async with client:
|
|
557
|
+
self.mqttc = client # Store reference for publishing methods
|
|
558
|
+
|
|
559
|
+
# Subscribe to topics
|
|
560
|
+
for ctrl in self.control:
|
|
561
|
+
await client.subscribe(f"{self.discovery_prefix}/light/{ctrl.name}/#")
|
|
562
|
+
await client.subscribe(f"{self.discovery_prefix}/binary_sensor/{ctrl.name}/#")
|
|
563
|
+
await client.subscribe(f"{self.discovery_prefix}/sensor/{ctrl.name}/#")
|
|
564
|
+
await client.subscribe(f"{self.discovery_prefix}/switch/{ctrl.name}/#")
|
|
565
|
+
await client.subscribe(f"{self.discovery_prefix}/event/{ctrl.name}/#")
|
|
566
|
+
await client.subscribe(f"{self.discovery_prefix}/select/{ctrl.name}/#")
|
|
567
|
+
await client.subscribe(f"{self.discovery_prefix}/device_automation/{ctrl.name}/#")
|
|
568
|
+
|
|
569
|
+
self.logger.info("Successfully connected to MQTT broker")
|
|
570
|
+
# Reflect current Zen state (offline until zen.start / reconnect)
|
|
571
|
+
await self._publish_zen_availability()
|
|
572
|
+
|
|
573
|
+
# Process messages
|
|
574
|
+
async for message in client.messages:
|
|
575
|
+
await self._mqtt_on_message(message)
|
|
576
|
+
|
|
577
|
+
except asyncio.CancelledError:
|
|
578
|
+
self.logger.info("MQTT message handler cancelled")
|
|
579
|
+
break
|
|
580
|
+
except aiomqtt.MqttError as e:
|
|
581
|
+
self.logger.warning(f"MQTT connection lost: {e}")
|
|
582
|
+
self.logger.info(f"Reconnecting in {interval} seconds...")
|
|
583
|
+
await asyncio.sleep(interval)
|
|
584
|
+
# Reset interval for next attempt
|
|
585
|
+
interval = Const.MQTT_RECONNECT_MIN_DELAY
|
|
586
|
+
except Exception as e:
|
|
587
|
+
self.logger.error(f"Unexpected error in MQTT message handler: {e}")
|
|
588
|
+
self.logger.info(f"Retrying in {interval} seconds...")
|
|
589
|
+
await asyncio.sleep(interval)
|
|
590
|
+
# Exponential backoff for unexpected errors
|
|
591
|
+
interval = min(interval * 2, Const.MQTT_RECONNECT_MAX_DELAY)
|
|
592
|
+
|
|
593
|
+
async def _mqtt_on_message(self, msg: aiomqtt.Message) -> None:
|
|
594
|
+
"""Handle incoming MQTT messages with improved error handling."""
|
|
595
|
+
try:
|
|
596
|
+
|
|
597
|
+
# Debug
|
|
598
|
+
payload_str = msg.payload.decode('UTF-8') if msg.payload else ""
|
|
599
|
+
topic_str = str(msg.topic)
|
|
600
|
+
# self.logger.debug(f"MQTT received - {topic_str}: {payload_str}")
|
|
601
|
+
# print(Fore.YELLOW + f"MQTT received - {topic_str}: " + Style.DIM + f"{payload_str}" + Style.RESET_ALL)
|
|
602
|
+
|
|
603
|
+
# Get the last part of the topic
|
|
604
|
+
command = topic_str.split('/')[-1]
|
|
605
|
+
|
|
606
|
+
# Config commands are ignored
|
|
607
|
+
if command == "config":
|
|
608
|
+
# If we haven't started setup yet, it's a retained topic, so add to delete list
|
|
609
|
+
if not self.setup_started:
|
|
610
|
+
self.config_topics_to_delete.append(topic_str)
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
# State commands are always ignored
|
|
614
|
+
if command == "state":
|
|
615
|
+
return
|
|
616
|
+
|
|
617
|
+
# Only set commands from here onwards
|
|
618
|
+
if command != "set":
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
# Get the base topic from the message
|
|
622
|
+
base_topic = topic_str.rsplit('/', 1)[0]
|
|
623
|
+
|
|
624
|
+
# Find the matching object in our map
|
|
625
|
+
target_object = self.topic_object.get(base_topic)
|
|
626
|
+
|
|
627
|
+
# If we don't have an object, ignore the message
|
|
628
|
+
if not target_object:
|
|
629
|
+
self.logger.debug(f"No matching object found for {base_topic}")
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
# If setup is not complete, ignore the message
|
|
633
|
+
if not self.setup_complete:
|
|
634
|
+
self.logger.debug(f"Setup not complete, ignoring message {msg.topic}")
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
# Convert payload to str
|
|
638
|
+
payload = msg.payload.decode('UTF-8') if msg.payload else ""
|
|
639
|
+
|
|
640
|
+
# Match on object type
|
|
641
|
+
if isinstance(target_object, ZenController):
|
|
642
|
+
await self._mqtt_profile_change(target_object, payload)
|
|
643
|
+
elif isinstance(target_object, ZenGroup):
|
|
644
|
+
if "/select/" in base_topic:
|
|
645
|
+
await self._mqtt_groupscene_change(target_object, payload)
|
|
646
|
+
elif "/light/" in base_topic:
|
|
647
|
+
await self._mqtt_light_change(target_object, json.loads(payload))
|
|
648
|
+
elif isinstance(target_object, ZenLight):
|
|
649
|
+
await self._mqtt_light_change(target_object, json.loads(payload))
|
|
650
|
+
elif isinstance(target_object, ZenSystemVariable):
|
|
651
|
+
await self._mqtt_system_variable_change(target_object, payload)
|
|
652
|
+
elif isinstance(target_object, ZenMotionSensor):
|
|
653
|
+
return # Read only
|
|
654
|
+
else:
|
|
655
|
+
print(f"Unknown object type found in self.topic_object: {type(target_object)}")
|
|
656
|
+
|
|
657
|
+
except json.JSONDecodeError as e:
|
|
658
|
+
self.logger.error(f"Invalid JSON payload: {e}")
|
|
659
|
+
|
|
660
|
+
# ================================
|
|
661
|
+
# MQTT PUBLISHING
|
|
662
|
+
# ================================
|
|
663
|
+
|
|
664
|
+
def _client_data_for_object(self, object: Any, component: str, attributes: dict = {}) -> dict:
|
|
665
|
+
if isinstance(object, ZenController):
|
|
666
|
+
ctrl = object
|
|
667
|
+
serial = ctrl.mac
|
|
668
|
+
mqtt_target = "profile"
|
|
669
|
+
elif isinstance(object, ZenGroup): # ZenGroup inherits from ZenLight, so needs to be before ZenLight
|
|
670
|
+
group = object
|
|
671
|
+
addr = group.address
|
|
672
|
+
ctrl = addr.controller
|
|
673
|
+
serial = ""
|
|
674
|
+
mqtt_target = f"group{addr.number}"
|
|
675
|
+
elif isinstance(object, ZenLight):
|
|
676
|
+
light = object
|
|
677
|
+
addr = light.address
|
|
678
|
+
ctrl = addr.controller
|
|
679
|
+
serial = light.serial
|
|
680
|
+
mqtt_target = f"ecg{addr.number}"
|
|
681
|
+
elif isinstance(object, ZenButton):
|
|
682
|
+
button = object
|
|
683
|
+
inst = button.instance
|
|
684
|
+
addr = inst.address
|
|
685
|
+
ctrl = addr.controller
|
|
686
|
+
serial = button.serial
|
|
687
|
+
mqtt_target = f"ecd{addr.number}_inst{inst.number}"
|
|
688
|
+
elif isinstance(object, ZenMotionSensor):
|
|
689
|
+
sensor = object
|
|
690
|
+
inst = sensor.instance
|
|
691
|
+
addr = inst.address
|
|
692
|
+
ctrl = addr.controller
|
|
693
|
+
serial = sensor.serial
|
|
694
|
+
mqtt_target = f"ecd{addr.number}_inst{inst.number}"
|
|
695
|
+
elif isinstance(object, ZenSystemVariable):
|
|
696
|
+
sysvar = object
|
|
697
|
+
ctrl = sysvar.controller
|
|
698
|
+
serial = component
|
|
699
|
+
mqtt_target = f"sv{sysvar.id}"
|
|
700
|
+
else:
|
|
701
|
+
raise ValueError(f"Unknown object type: {type(object)}")
|
|
702
|
+
# Build default attributes - passed attributes can override default_entity_id
|
|
703
|
+
default_attrs = {
|
|
704
|
+
"component": component,
|
|
705
|
+
"default_entity_id": f"{component}.{ctrl.name}_{mqtt_target}",
|
|
706
|
+
"unique_id": f"{ctrl.name}_{mqtt_target}_{serial}",
|
|
707
|
+
"device": {
|
|
708
|
+
"manufacturer": "Zencontrol",
|
|
709
|
+
"identifiers": f"zencontrol-{ctrl.name}",
|
|
710
|
+
"sw_version": ctrl.version,
|
|
711
|
+
"name": ctrl.label,
|
|
712
|
+
},
|
|
713
|
+
"availability_topic": f"{Const.MQTT_SERVICE_PREFIX}/{ctrl.name}/availability",
|
|
714
|
+
}
|
|
715
|
+
# Merge: attributes take precedence over default_attrs
|
|
716
|
+
object.client_data[component] = object.client_data.get(component, {}) | {
|
|
717
|
+
"component": component,
|
|
718
|
+
"attributes": default_attrs | attributes,
|
|
719
|
+
"mqtt_target": mqtt_target,
|
|
720
|
+
"mqtt_topic": f"{self.discovery_prefix}/{component}/{ctrl.name}/{mqtt_target}",
|
|
721
|
+
}
|
|
722
|
+
return object.client_data[component]
|
|
723
|
+
|
|
724
|
+
async def _publish_config(self, topic: str, config: dict, object: Any = None, retain: bool = True) -> None:
|
|
725
|
+
if object:
|
|
726
|
+
self.topic_object[topic] = object
|
|
727
|
+
config_topic = f"{topic}/config"
|
|
728
|
+
config_json = json.dumps(config)
|
|
729
|
+
await self.mqttc.publish(config_topic, config_json, retain=retain)
|
|
730
|
+
if config_topic in self.config_topics_to_delete: self.config_topics_to_delete.remove(config_topic)
|
|
731
|
+
# self.logger.debug(f"MQTT sent - {topic}/config: {config_json}")
|
|
732
|
+
# print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/config: " + Style.DIM + f"{config_json}" + Style.RESET_ALL)
|
|
733
|
+
|
|
734
|
+
async def _publish_state(self, topic: str, state: str|dict|None, retain: bool = False) -> None:
|
|
735
|
+
if isinstance(state, dict): state = json.dumps(state)
|
|
736
|
+
await self.mqttc.publish(f"{topic}/state", state, retain=retain)
|
|
737
|
+
|
|
738
|
+
if "/light/" in topic or "/group/" in topic:
|
|
739
|
+
self.logger.debug(f"MQTT sent - {topic}/state: {state}")
|
|
740
|
+
# self.logger.debug(f"MQTT sent - {topic}/state: {state}")
|
|
741
|
+
# print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/state: " + Style.DIM + f"{state}" + Style.RESET_ALL)
|
|
742
|
+
|
|
743
|
+
async def _publish_event(self, topic: str, event: str, retain: bool = False) -> None:
|
|
744
|
+
await self.mqttc.publish(f"{topic}/event", event, retain=retain)
|
|
745
|
+
# self.logger.debug(f"MQTT sent - {topic}/event: {event}")
|
|
746
|
+
# print(Fore.LIGHTRED_EX + f"MQTT sent - {topic}/event: " + Style.DIM + f"{event}" + Style.RESET_ALL)
|
|
747
|
+
|
|
748
|
+
async def delete_retained_topics(self) -> None:
|
|
749
|
+
for topic in self.config_topics_to_delete:
|
|
750
|
+
await self.mqttc.publish(topic, None, retain=True)
|
|
751
|
+
# self.logger.debug(f"MQTT deleted - {topic}")
|
|
752
|
+
# print(Fore.RED + f"•• MQTT DELETED •• " + Style.DIM + f"{topic}" + Style.RESET_ALL)
|
|
753
|
+
|
|
754
|
+
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
|
755
|
+
|
|
756
|
+
# ================================
|
|
757
|
+
# PROFILES
|
|
758
|
+
# ================================
|
|
759
|
+
|
|
760
|
+
async def setup_profiles(self) -> set[ZenProfile]:
|
|
761
|
+
"""Initialize all profiles for Home Assistant auto-discovery."""
|
|
762
|
+
all_profiles = set()
|
|
763
|
+
for ctrl in self.control:
|
|
764
|
+
client_data = self._client_data_for_object(ctrl, "select")
|
|
765
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
766
|
+
profiles = await self.zen.get_profiles(ctrl)
|
|
767
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
768
|
+
"name": f"{ctrl.label} Profile",
|
|
769
|
+
"command_topic": f"{mqtt_topic}/set",
|
|
770
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
771
|
+
"options": [
|
|
772
|
+
profile.label for profile in profiles
|
|
773
|
+
]
|
|
774
|
+
}
|
|
775
|
+
await self._publish_config(mqtt_topic, config_dict, object=ctrl)
|
|
776
|
+
await self._publish_state(mqtt_topic, ctrl.profile.label)
|
|
777
|
+
all_profiles.update(profiles)
|
|
778
|
+
return all_profiles
|
|
779
|
+
|
|
780
|
+
async def _mqtt_profile_change(self, ctrl: ZenController, payload: str) -> None:
|
|
781
|
+
print(f"HA asking to change profile of {ctrl.name} to {payload}")
|
|
782
|
+
await ctrl.switch_to_profile(payload)
|
|
783
|
+
|
|
784
|
+
async def _zen_profile_change(self, profile: ZenProfile) -> None:
|
|
785
|
+
print(f"Zen to HA: profile changed to {profile}")
|
|
786
|
+
|
|
787
|
+
ctrl = profile.controller
|
|
788
|
+
mqtt_topic = ctrl.client_data.get("select", {}).get('mqtt_topic', None)
|
|
789
|
+
if not mqtt_topic:
|
|
790
|
+
self.logger.error(f"Controller {ctrl} has no MQTT topic")
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
await self._publish_state(mqtt_topic, profile.label)
|
|
794
|
+
|
|
795
|
+
# ================================
|
|
796
|
+
# LIGHTS
|
|
797
|
+
# ================================
|
|
798
|
+
|
|
799
|
+
async def setup_lights(self) -> set[ZenLight]:
|
|
800
|
+
"""Initialize all lights for Home Assistant auto-discovery."""
|
|
801
|
+
lights = await self.zen.get_lights()
|
|
802
|
+
|
|
803
|
+
# First, publish all configs (this is fast and doesn't need rate limiting)
|
|
804
|
+
for light in lights:
|
|
805
|
+
client_data = self._client_data_for_object(light, "light")
|
|
806
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
807
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
808
|
+
"name": light.label,
|
|
809
|
+
"schema": "json",
|
|
810
|
+
"payload_off": "OFF",
|
|
811
|
+
"payload_on": "ON",
|
|
812
|
+
"command_topic": f"{mqtt_topic}/set",
|
|
813
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
814
|
+
"json_attributes_topic": f"{mqtt_topic}/attributes",
|
|
815
|
+
"effect": False,
|
|
816
|
+
"retain": False,
|
|
817
|
+
"brightness": light.features["brightness"],
|
|
818
|
+
"supported_color_modes": [],
|
|
819
|
+
}
|
|
820
|
+
if light.features["RGBWW"]:
|
|
821
|
+
config_dict["supported_color_modes"] = ["rgbww"]
|
|
822
|
+
elif light.features["RGBW"]:
|
|
823
|
+
config_dict["supported_color_modes"] = ["rgbw"]
|
|
824
|
+
elif light.features["RGB"]:
|
|
825
|
+
config_dict["supported_color_modes"] = ["rgb"]
|
|
826
|
+
elif light.features["temperature"]:
|
|
827
|
+
config_dict["supported_color_modes"] = ["color_temp"]
|
|
828
|
+
config_dict["min_mireds"] = self.kelvin_to_mireds(light.properties["max_kelvin"]) if light.properties["max_kelvin"] is not None else None
|
|
829
|
+
config_dict["max_mireds"] = self.kelvin_to_mireds(light.properties["min_kelvin"]) if light.properties["min_kelvin"] is not None else None
|
|
830
|
+
elif light.features["brightness"]:
|
|
831
|
+
config_dict["supported_color_modes"] = ["brightness"]
|
|
832
|
+
else:
|
|
833
|
+
config_dict["supported_color_modes"] = ["onoff"]
|
|
834
|
+
|
|
835
|
+
await self._publish_config(mqtt_topic, config_dict, object=light)
|
|
836
|
+
|
|
837
|
+
# Then, sync all lights with rate limiting to prevent server overload
|
|
838
|
+
sync_coros = [light.refresh_state_from_controller() for light in lights]
|
|
839
|
+
await self.rate_limiter.execute_batch(sync_coros)
|
|
840
|
+
|
|
841
|
+
# Return all lights
|
|
842
|
+
return lights
|
|
843
|
+
|
|
844
|
+
async def _mqtt_light_change(self, light: ZenLight|ZenGroup, payload: dict[str, Any]) -> None:
|
|
845
|
+
addr = light.address
|
|
846
|
+
ctrl = addr.controller
|
|
847
|
+
state: Optional[str] = payload.get("state", None)
|
|
848
|
+
brightness: Optional[int] = payload.get("brightness", None)
|
|
849
|
+
mireds: Optional[int] = payload.get("color_temp", None)
|
|
850
|
+
|
|
851
|
+
# If brightness or temperature is set
|
|
852
|
+
if brightness is not None or mireds is not None:
|
|
853
|
+
args = {}
|
|
854
|
+
if brightness is not None: args["level"] = self.brightness_to_arc(brightness)
|
|
855
|
+
if mireds is not None: args["colour"] = ZenColour(type=ZenColourType.TC, kelvin=self.mireds_to_kelvin(mireds))
|
|
856
|
+
self.logger.info(f"♥️💡 Command from HA: {ctrl.name} setting gear {addr.number} to {args}")
|
|
857
|
+
await light.set(**args)
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
# If switched on/off in HA
|
|
861
|
+
if state == "OFF":
|
|
862
|
+
self.logger.info(f"♥️💡 Command from HA: {ctrl.name} turning gear {addr.number} OFF")
|
|
863
|
+
await light.off(fade=True)
|
|
864
|
+
elif state == "ON":
|
|
865
|
+
self.logger.info(f"♥️💡 Command from HA: {ctrl.name} turning gear {addr.number} ON")
|
|
866
|
+
await light.on()
|
|
867
|
+
|
|
868
|
+
async def _zen_light_change(self, light: ZenLight, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None) -> None:
|
|
869
|
+
typestr = "group" if light.address.type == ZenAddressType.GROUP else "light"
|
|
870
|
+
emoji = "👥" if light.address.type == ZenAddressType.GROUP else "💡"
|
|
871
|
+
self.logger.info(f"🩵{emoji} Event from Zen: {typestr} {light.address.number} level {level if level is not None else '--'} colour {colour if colour is not None else '--'} scene {scene if scene is not None else '--'}")
|
|
872
|
+
|
|
873
|
+
# log traceback
|
|
874
|
+
# self.logger.debug(f" stack trace: {traceback.format_stack()}")
|
|
875
|
+
|
|
876
|
+
mqtt_topic = light.client_data.get("light", {}).get('mqtt_topic', None)
|
|
877
|
+
if not mqtt_topic:
|
|
878
|
+
self.logger.error(f"Light {light} has no MQTT topic")
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
new_state = {
|
|
882
|
+
"state": "OFF" if light.level == 0 else "ON"
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if light.level is not None and light.level > 0:
|
|
886
|
+
new_state["brightness"] = self.arc_to_brightness(light.level)
|
|
887
|
+
|
|
888
|
+
if light.colour and light.colour.type == ZenColourType.TC:
|
|
889
|
+
new_state["color_mode"] = "color_temp"
|
|
890
|
+
if light.colour.kelvin is not None:
|
|
891
|
+
new_state["color_temp"] = self.kelvin_to_mireds(light.colour.kelvin)
|
|
892
|
+
|
|
893
|
+
await self._publish_state(mqtt_topic, new_state)
|
|
894
|
+
|
|
895
|
+
# ================================
|
|
896
|
+
# GROUPS
|
|
897
|
+
# ================================
|
|
898
|
+
|
|
899
|
+
async def setup_groups(self) -> set[ZenGroup]:
|
|
900
|
+
"""Initialize all groups for Home Assistant auto-discovery."""
|
|
901
|
+
groups = await self.zen.get_groups()
|
|
902
|
+
# Group-lights
|
|
903
|
+
for group in groups:
|
|
904
|
+
if group.lights:
|
|
905
|
+
client_data = self._client_data_for_object(group, "light")
|
|
906
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
907
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
908
|
+
"name": group.label,
|
|
909
|
+
"schema": "json",
|
|
910
|
+
"payload_off": "OFF",
|
|
911
|
+
"payload_on": "ON",
|
|
912
|
+
"command_topic": f"{mqtt_topic}/set",
|
|
913
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
914
|
+
"json_attributes_topic": f"{mqtt_topic}/attributes",
|
|
915
|
+
"effect": False,
|
|
916
|
+
"retain": False,
|
|
917
|
+
"brightness": False,
|
|
918
|
+
}
|
|
919
|
+
if group.contains_temperature_lights():
|
|
920
|
+
config_dict = config_dict | {
|
|
921
|
+
"brightness": True,
|
|
922
|
+
"supported_color_modes": ["color_temp"],
|
|
923
|
+
"min_mireds": self.kelvin_to_mireds(group.properties["max_kelvin"]),
|
|
924
|
+
"max_mireds": self.kelvin_to_mireds(group.properties["min_kelvin"]),
|
|
925
|
+
}
|
|
926
|
+
elif group.contains_dimmable_lights():
|
|
927
|
+
config_dict = config_dict | {
|
|
928
|
+
"brightness": True,
|
|
929
|
+
"supported_color_modes": ["brightness"],
|
|
930
|
+
}
|
|
931
|
+
else:
|
|
932
|
+
config_dict = config_dict | {
|
|
933
|
+
"supported_color_modes": ["onoff"],
|
|
934
|
+
}
|
|
935
|
+
await self._publish_config(mqtt_topic, config_dict, object=group)
|
|
936
|
+
# Get the latest state from the controller and trigger an event, which then sends a state update
|
|
937
|
+
await group.refresh_state_from_controller()
|
|
938
|
+
|
|
939
|
+
# Group-scenes
|
|
940
|
+
for group in groups:
|
|
941
|
+
if group.lights:
|
|
942
|
+
client_data = self._client_data_for_object(group, "select")
|
|
943
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
944
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
945
|
+
"name": group.label,
|
|
946
|
+
"command_topic": f"{mqtt_topic}/set",
|
|
947
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
948
|
+
"options": group.get_scene_labels(exclude_none=True),
|
|
949
|
+
}
|
|
950
|
+
await self._publish_config(mqtt_topic, config_dict, object=group)
|
|
951
|
+
scene_label = group.get_scene_label_from_number(group.scene) if group.scene is not None else None
|
|
952
|
+
await self._publish_state(mqtt_topic, scene_label if scene_label is not None else "None")
|
|
953
|
+
|
|
954
|
+
# Return all groups
|
|
955
|
+
return groups
|
|
956
|
+
|
|
957
|
+
async def _mqtt_groupscene_change(self, group: ZenGroup, payload: str) -> None:
|
|
958
|
+
self.logger.info(f"♥️👥 Command from HA: group {group.address.number} to scene {payload}")
|
|
959
|
+
await group.set_scene(payload)
|
|
960
|
+
|
|
961
|
+
# mqtt group light change calls _mqtt_light_change
|
|
962
|
+
|
|
963
|
+
async def _zen_group_change(self, group: ZenGroup, level: Optional[int] = None, colour: Optional[ZenColour] = None, scene: Optional[int] = None, discoordinated: Optional[bool] = None) -> None:
|
|
964
|
+
select_mqtt_topic = group.client_data.get("select", {}).get('mqtt_topic', None)
|
|
965
|
+
|
|
966
|
+
# Get the scene label for the ID from the group
|
|
967
|
+
if select_mqtt_topic and scene is not None:
|
|
968
|
+
scene_label = group.get_scene_label_from_number(scene)
|
|
969
|
+
if scene_label is not None:
|
|
970
|
+
await self._publish_state(select_mqtt_topic, scene_label)
|
|
971
|
+
else:
|
|
972
|
+
await self._publish_state(select_mqtt_topic, "None")
|
|
973
|
+
self.logger.warning(f"Group {group} has no scene with ID {scene}")
|
|
974
|
+
|
|
975
|
+
# If discoordinated, set the group-light's state to null and return
|
|
976
|
+
if discoordinated:
|
|
977
|
+
self.logger.info(f"🩵👥 Event from Zen: group {group.address.number} discoordinated")
|
|
978
|
+
await self._publish_state(select_mqtt_topic, "None")
|
|
979
|
+
light_mqtt_topic = group.client_data.get("light", {}).get('mqtt_topic', None)
|
|
980
|
+
await self._publish_state(light_mqtt_topic, {"state": None})
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
# Do light stuff
|
|
984
|
+
await self._zen_light_change(light=group, level=level, colour=colour, scene=scene)
|
|
985
|
+
|
|
986
|
+
# ================================
|
|
987
|
+
# BUTTONS
|
|
988
|
+
# ================================
|
|
989
|
+
|
|
990
|
+
async def setup_buttons(self) -> set[ZenButton]:
|
|
991
|
+
"""Initialize all buttons found on the DALI bus for Home Assistant auto-discovery."""
|
|
992
|
+
buttons = await self.zen.get_buttons()
|
|
993
|
+
for button in buttons:
|
|
994
|
+
client_data = self._client_data_for_object(button, "device_automation")
|
|
995
|
+
button.long_press_time = Const.DEFAULT_LONG_PRESS_TIME
|
|
996
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
997
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
998
|
+
"automation_type": "trigger",
|
|
999
|
+
"subtype": re.sub(r'[^a-z0-9]', '_', button.label.lower()) + "_" + re.sub(r'[^a-z0-9]', '_', button.instance_label.lower()),
|
|
1000
|
+
"type": "button_short_press",
|
|
1001
|
+
"payload": "button_short_press",
|
|
1002
|
+
"topic": f"{mqtt_topic}/event",
|
|
1003
|
+
}
|
|
1004
|
+
await self._publish_config(mqtt_topic, config_dict, object=button)
|
|
1005
|
+
# For long press, we use a different topic for the config, but the same topic for the event payload
|
|
1006
|
+
config_dict = config_dict | {
|
|
1007
|
+
"type": "button_long_press",
|
|
1008
|
+
"payload": "button_long_press",
|
|
1009
|
+
}
|
|
1010
|
+
await self._publish_config(mqtt_topic + "_long_press", config_dict, object=button)
|
|
1011
|
+
|
|
1012
|
+
# Return all buttons
|
|
1013
|
+
return buttons
|
|
1014
|
+
|
|
1015
|
+
async def _zen_button_press(self, button: ZenButton) -> None:
|
|
1016
|
+
self.logger.debug(f"🩵👆 Event from Zen: button press {button}")
|
|
1017
|
+
mqtt_topic = button.client_data.get("device_automation", {}).get("mqtt_topic", None)
|
|
1018
|
+
if not mqtt_topic:
|
|
1019
|
+
self.logger.error(f"Button {button} has no MQTT topic")
|
|
1020
|
+
return
|
|
1021
|
+
await self._publish_event(mqtt_topic, "button_short_press")
|
|
1022
|
+
|
|
1023
|
+
async def _zen_button_long_press(self, button: ZenButton) -> None:
|
|
1024
|
+
self.logger.debug(f"🩵👆 Event from Zen: button LONG press {button}")
|
|
1025
|
+
mqtt_topic = button.client_data.get("device_automation", {}).get("mqtt_topic", None)
|
|
1026
|
+
if not mqtt_topic:
|
|
1027
|
+
self.logger.error(f"Button {button} has no MQTT topic")
|
|
1028
|
+
return
|
|
1029
|
+
await self._publish_event(mqtt_topic, "button_long_press")
|
|
1030
|
+
|
|
1031
|
+
# ================================
|
|
1032
|
+
# MOTION SENSORS
|
|
1033
|
+
# ================================
|
|
1034
|
+
|
|
1035
|
+
async def setup_motion_sensors(self) -> set[ZenMotionSensor]:
|
|
1036
|
+
"""Initialize all motion sensors found on the DALI bus for Home Assistant auto-discovery."""
|
|
1037
|
+
sensors = await self.zen.get_motion_sensors()
|
|
1038
|
+
for sensor in sensors:
|
|
1039
|
+
client_data = self._client_data_for_object(sensor, "binary_sensor")
|
|
1040
|
+
mqtt_topic = client_data['mqtt_topic']
|
|
1041
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
1042
|
+
"name": sensor.instance_label,
|
|
1043
|
+
"device_class": "motion",
|
|
1044
|
+
"payload_off": "OFF",
|
|
1045
|
+
"payload_on": "ON",
|
|
1046
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
1047
|
+
"json_attributes_topic": f"{mqtt_topic}/attributes",
|
|
1048
|
+
"retain": False,
|
|
1049
|
+
}
|
|
1050
|
+
await self._publish_config(mqtt_topic, config_dict, object=sensor)
|
|
1051
|
+
await self._publish_state(mqtt_topic, "ON" if sensor.occupied else "OFF")
|
|
1052
|
+
|
|
1053
|
+
# Return all motion sensors
|
|
1054
|
+
return sensors
|
|
1055
|
+
|
|
1056
|
+
async def _zen_motion_event(self, sensor: ZenMotionSensor, occupied: bool) -> None:
|
|
1057
|
+
self.logger.debug(f"🩵👀 Event from Zen: sensor {sensor} occupied: {occupied}")
|
|
1058
|
+
mqtt_topic = sensor.client_data.get("binary_sensor", {}).get("mqtt_topic", None)
|
|
1059
|
+
if not mqtt_topic:
|
|
1060
|
+
self.logger.error(f"Sensor {sensor} has no MQTT topic")
|
|
1061
|
+
return
|
|
1062
|
+
await self._publish_state(mqtt_topic, "ON" if occupied else "OFF")
|
|
1063
|
+
|
|
1064
|
+
# ================================
|
|
1065
|
+
# SYSTEM VARIABLES
|
|
1066
|
+
# ================================
|
|
1067
|
+
|
|
1068
|
+
async def setup_system_variables(self) -> set[ZenSystemVariable]:
|
|
1069
|
+
"""Initialize system variables in config.yaml for Home Assistant auto-discovery."""
|
|
1070
|
+
|
|
1071
|
+
# On first run, prep system variables with client_data
|
|
1072
|
+
if not self.system_variables:
|
|
1073
|
+
for sv in self.sv_config:
|
|
1074
|
+
ctrl: ZenController = sv['controller']
|
|
1075
|
+
zsv = ctrl.get_sysvar(sv['id'])
|
|
1076
|
+
attr = sv['attributes'] | {
|
|
1077
|
+
"default_entity_id": f"{sv['component']}.{sv['object_id']}",
|
|
1078
|
+
"unique_id": f"{ctrl.name}_{sv['object_id']}"
|
|
1079
|
+
}
|
|
1080
|
+
self._client_data_for_object(zsv, sv['component'], attributes=attr)
|
|
1081
|
+
self.system_variables.append(zsv)
|
|
1082
|
+
|
|
1083
|
+
for zsv in self.system_variables:
|
|
1084
|
+
if zsv.client_data.get("switch", None):
|
|
1085
|
+
client_data = zsv.client_data["switch"]
|
|
1086
|
+
mqtt_topic = client_data["mqtt_topic"]
|
|
1087
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
1088
|
+
"component": "switch",
|
|
1089
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
1090
|
+
"command_topic": f"{mqtt_topic}/set",
|
|
1091
|
+
"payload_off": "OFF",
|
|
1092
|
+
"payload_on": "ON",
|
|
1093
|
+
"retain": False,
|
|
1094
|
+
}
|
|
1095
|
+
elif zsv.client_data.get("sensor", None):
|
|
1096
|
+
client_data = zsv.client_data["sensor"]
|
|
1097
|
+
mqtt_topic = client_data["mqtt_topic"]
|
|
1098
|
+
config_dict = self.global_config | client_data.get("attributes",{}) | {
|
|
1099
|
+
"component": "sensor",
|
|
1100
|
+
"state_topic": f"{mqtt_topic}/state",
|
|
1101
|
+
"retain": False,
|
|
1102
|
+
}
|
|
1103
|
+
else:
|
|
1104
|
+
continue
|
|
1105
|
+
|
|
1106
|
+
await self._publish_config(mqtt_topic, config_dict, object=zsv)
|
|
1107
|
+
await self._publish_state(mqtt_topic, await zsv.get_value())
|
|
1108
|
+
|
|
1109
|
+
# Return all system variables
|
|
1110
|
+
return self.system_variables
|
|
1111
|
+
|
|
1112
|
+
async def _mqtt_system_variable_change(self, sysvar: ZenSystemVariable, payload: str) -> None:
|
|
1113
|
+
self.logger.debug(f"♥️⚡️ Command from HA: {sysvar.controller.name} system variable {sysvar.id} set to {payload}")
|
|
1114
|
+
if sysvar.client_data.get("switch", None):
|
|
1115
|
+
await sysvar.set_value(1 if payload == "ON" else 0)
|
|
1116
|
+
elif sysvar.client_data.get("sensor", None):
|
|
1117
|
+
return # Read only
|
|
1118
|
+
|
|
1119
|
+
async def _zen_system_variable_change(self, system_variable: ZenSystemVariable, value:int, changed: bool, by_me: bool) -> None:
|
|
1120
|
+
self.logger.debug(f"🩵⚡️ Event from Zen: {system_variable.controller.name} system variable {system_variable.id} set to {value}")
|
|
1121
|
+
# print(f"System Variable Change Event - controller {system_variable.controller.name} system_variable {system_variable.id} value {value} changed {changed} by_me {by_me}")
|
|
1122
|
+
if system_variable.client_data.get("switch", None):
|
|
1123
|
+
mqtt_topic = system_variable.client_data["switch"]["mqtt_topic"]
|
|
1124
|
+
await self._publish_state(mqtt_topic, "OFF" if value == 0 else "ON")
|
|
1125
|
+
elif system_variable.client_data.get("sensor", None):
|
|
1126
|
+
mqtt_topic = system_variable.client_data["sensor"]["mqtt_topic"]
|
|
1127
|
+
await self._publish_state(mqtt_topic, value)
|
|
1128
|
+
else:
|
|
1129
|
+
self.logger.error(f"Ignoring system variable {system_variable}")
|
|
1130
|
+
return
|
|
1131
|
+
|
|
1132
|
+
# Usage
|
|
1133
|
+
async def main():
|
|
1134
|
+
bridge = ZenMQTTBridge()
|
|
1135
|
+
try:
|
|
1136
|
+
await bridge.run()
|
|
1137
|
+
except KeyboardInterrupt:
|
|
1138
|
+
print("Shutting down...")
|
|
1139
|
+
await bridge.stop()
|
|
1140
|
+
|
|
1141
|
+
if __name__ == "__main__":
|
|
1142
|
+
asyncio.run(main())
|