-
-
Notifications
You must be signed in to change notification settings - Fork 30.5k
/
panel.py
397 lines (347 loc) · 13.3 KB
/
panel.py
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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
"""Support for Konnected devices."""
import asyncio
import logging
import konnected
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_STATE,
CONF_ACCESS_TOKEN,
CONF_BINARY_SENSORS,
CONF_DEVICES,
CONF_DISCOVERY,
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PIN,
CONF_PORT,
CONF_REPEAT,
CONF_SENSORS,
CONF_SWITCHES,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import get_url
from .const import (
CONF_ACTIVATION,
CONF_API_HOST,
CONF_BLINK,
CONF_DEFAULT_OPTIONS,
CONF_DHT_SENSORS,
CONF_DS18B20_SENSORS,
CONF_INVERSE,
CONF_MOMENTARY,
CONF_PAUSE,
CONF_POLL_INTERVAL,
DOMAIN,
ENDPOINT_ROOT,
STATE_LOW,
ZONE_TO_PIN,
)
from .errors import CannotConnect
_LOGGER = logging.getLogger(__name__)
KONN_MODEL = "Konnected"
KONN_MODEL_PRO = "Konnected Pro"
# Indicate how each unit is controlled (pin or zone)
KONN_API_VERSIONS = {
KONN_MODEL: CONF_PIN,
KONN_MODEL_PRO: CONF_ZONE,
}
class AlarmPanel:
"""A representation of a Konnected alarm panel."""
def __init__(self, hass, config_entry):
"""Initialize the Konnected device."""
self.hass = hass
self.config_entry = config_entry
self.config = config_entry.data
self.options = config_entry.options or config_entry.data.get(
CONF_DEFAULT_OPTIONS, {}
)
self.host = self.config.get(CONF_HOST)
self.port = self.config.get(CONF_PORT)
self.client = None
self.status = None
self.api_version = KONN_API_VERSIONS[KONN_MODEL]
self.connected = False
self.connect_attempts = 0
self.cancel_connect_retry = None
@property
def device_id(self):
"""Device id is the chipId (pro) or MAC address as string with punctuation removed."""
return self.config.get(CONF_ID)
@property
def stored_configuration(self):
"""Return the configuration stored in `hass.data` for this device."""
return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)
@property
def available(self):
"""Return whether the device is available."""
return self.connected
def format_zone(self, zone, other_items=None):
"""Get zone or pin based dict based on the client type."""
payload = {
self.api_version: zone
if self.api_version == CONF_ZONE
else ZONE_TO_PIN[zone]
}
payload.update(other_items or {})
return payload
async def async_connect(self, now=None):
"""Connect to and setup a Konnected device."""
if self.connected:
return
if self.cancel_connect_retry:
# cancel any pending connect attempt and try now
self.cancel_connect_retry()
try:
self.client = konnected.Client(
host=self.host,
port=str(self.port),
websession=aiohttp_client.async_get_clientsession(self.hass),
)
self.status = await self.client.get_status()
self.api_version = KONN_API_VERSIONS.get(
self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL]
)
_LOGGER.info(
"Connected to new %s device", self.status.get("model", "Konnected")
)
_LOGGER.debug(self.status)
await self.async_update_initial_states()
# brief delay to allow processing of recent status req
await asyncio.sleep(0.1)
await self.async_sync_device_config()
except self.client.ClientError as err:
_LOGGER.warning("Exception trying to connect to panel: %s", err)
# retry in a bit, never more than ~3 min
self.connect_attempts += 1
self.cancel_connect_retry = async_call_later(
self.hass, 2 ** min(self.connect_attempts, 5) * 5, self.async_connect
)
return
self.connect_attempts = 0
self.connected = True
_LOGGER.info(
(
"Set up Konnected device %s. Open http://%s:%s in a "
"web browser to view device status"
),
self.device_id,
self.host,
self.port,
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, self.status.get("mac"))},
identifiers={(DOMAIN, self.device_id)},
manufacturer="Konnected.io",
name=self.config_entry.title,
model=self.config_entry.title,
sw_version=self.status.get("swVersion"),
)
async def update_switch(self, zone, state, momentary=None, times=None, pause=None):
"""Update the state of a switchable output."""
try:
if self.client:
if self.api_version == CONF_ZONE:
return await self.client.put_zone(
zone,
state,
momentary,
times,
pause,
)
# device endpoint uses pin number instead of zone
return await self.client.put_device(
ZONE_TO_PIN[zone],
state,
momentary,
times,
pause,
)
except self.client.ClientError as err:
_LOGGER.warning("Exception trying to update panel: %s", err)
raise CannotConnect
async def async_save_data(self):
"""Save the device configuration to `hass.data`."""
binary_sensors = {}
for entity in self.options.get(CONF_BINARY_SENSORS) or []:
zone = entity[CONF_ZONE]
binary_sensors[zone] = {
CONF_TYPE: entity[CONF_TYPE],
CONF_NAME: entity.get(
CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}"
),
CONF_INVERSE: entity.get(CONF_INVERSE),
ATTR_STATE: None,
}
_LOGGER.debug(
"Set up binary_sensor %s (initial state: %s)",
binary_sensors[zone].get("name"),
binary_sensors[zone].get(ATTR_STATE),
)
actuators = []
for entity in self.options.get(CONF_SWITCHES) or []:
zone = entity[CONF_ZONE]
act = {
CONF_ZONE: zone,
CONF_NAME: entity.get(
CONF_NAME,
f"Konnected {self.device_id[6:]} Actuator {zone}",
),
ATTR_STATE: None,
CONF_ACTIVATION: entity[CONF_ACTIVATION],
CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
CONF_PAUSE: entity.get(CONF_PAUSE),
CONF_REPEAT: entity.get(CONF_REPEAT),
}
actuators.append(act)
_LOGGER.debug("Set up switch %s", act)
sensors = []
for entity in self.options.get(CONF_SENSORS) or []:
zone = entity[CONF_ZONE]
sensor = {
CONF_ZONE: zone,
CONF_NAME: entity.get(
CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}"
),
CONF_TYPE: entity[CONF_TYPE],
CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL),
}
sensors.append(sensor)
_LOGGER.debug(
"Set up %s sensor %s (initial state: %s)",
sensor.get(CONF_TYPE),
sensor.get(CONF_NAME),
sensor.get(ATTR_STATE),
)
device_data = {
CONF_BINARY_SENSORS: binary_sensors,
CONF_SENSORS: sensors,
CONF_SWITCHES: actuators,
CONF_BLINK: self.options.get(CONF_BLINK),
CONF_DISCOVERY: self.options.get(CONF_DISCOVERY),
CONF_HOST: self.host,
CONF_PORT: self.port,
"panel": self,
}
if CONF_DEVICES not in self.hass.data[DOMAIN]:
self.hass.data[DOMAIN][CONF_DEVICES] = {}
_LOGGER.debug(
"Storing data in hass.data[%s][%s][%s]: %s",
DOMAIN,
CONF_DEVICES,
self.device_id,
device_data,
)
self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data
@callback
def async_binary_sensor_configuration(self):
"""Return the configuration map for syncing binary sensors."""
return [
self.format_zone(p) for p in self.stored_configuration[CONF_BINARY_SENSORS]
]
@callback
def async_actuator_configuration(self):
"""Return the configuration map for syncing actuators."""
return [
self.format_zone(
data[CONF_ZONE],
{"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)},
)
for data in self.stored_configuration[CONF_SWITCHES]
]
@callback
def async_dht_sensor_configuration(self):
"""Return the configuration map for syncing DHT sensors."""
return [
self.format_zone(
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
)
for sensor in self.stored_configuration[CONF_SENSORS]
if sensor[CONF_TYPE] == "dht"
]
@callback
def async_ds18b20_sensor_configuration(self):
"""Return the configuration map for syncing DS18B20 sensors."""
return [
self.format_zone(
sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
)
for sensor in self.stored_configuration[CONF_SENSORS]
if sensor[CONF_TYPE] == "ds18b20"
]
async def async_update_initial_states(self):
"""Update the initial state of each sensor from status poll."""
for sensor_data in self.status.get("sensors"):
sensor_config = self.stored_configuration[CONF_BINARY_SENSORS].get(
sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {}
)
entity_id = sensor_config.get(ATTR_ENTITY_ID)
state = bool(sensor_data.get(ATTR_STATE))
if sensor_config.get(CONF_INVERSE):
state = not state
async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state)
@callback
def async_desired_settings_payload(self):
"""Return a dict representing the desired device configuration."""
# keeping self.hass.data check for backwards compatibility
# newly configured integrations store this in the config entry
desired_api_host = self.options.get(CONF_API_HOST) or (
self.hass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hass)
)
desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
return {
"sensors": self.async_binary_sensor_configuration(),
"actuators": self.async_actuator_configuration(),
"dht_sensors": self.async_dht_sensor_configuration(),
"ds18b20_sensors": self.async_ds18b20_sensor_configuration(),
"auth_token": self.config.get(CONF_ACCESS_TOKEN),
"endpoint": desired_api_endpoint,
"blink": self.options.get(CONF_BLINK, True),
"discovery": self.options.get(CONF_DISCOVERY, True),
}
@callback
def async_current_settings_payload(self):
"""Return a dict of configuration currently stored on the device."""
settings = self.status["settings"] or {}
return {
"sensors": [
{self.api_version: s[self.api_version]}
for s in self.status.get("sensors")
],
"actuators": self.status.get("actuators"),
"dht_sensors": self.status.get(CONF_DHT_SENSORS),
"ds18b20_sensors": self.status.get(CONF_DS18B20_SENSORS),
"auth_token": settings.get("token"),
"endpoint": settings.get("endpoint"),
"blink": settings.get(CONF_BLINK),
"discovery": settings.get(CONF_DISCOVERY),
}
async def async_sync_device_config(self):
"""Sync the new zone configuration to the Konnected device if needed."""
_LOGGER.debug(
"Device %s settings payload: %s",
self.device_id,
self.async_desired_settings_payload(),
)
if (
self.async_desired_settings_payload()
!= self.async_current_settings_payload()
):
_LOGGER.info("Pushing settings to device %s", self.device_id)
await self.client.put_settings(**self.async_desired_settings_payload())
async def get_status(hass, host, port):
"""Get the status of a Konnected Panel."""
client = konnected.Client(
host, str(port), aiohttp_client.async_get_clientsession(hass)
)
try:
return await client.get_status()
except client.ClientError as err:
_LOGGER.error("Exception trying to get panel status: %s", err)
raise CannotConnect from err