Home Assistant Unofficial Reference 2024.12.1
panel.py
Go to the documentation of this file.
1 """Support for Konnected devices."""
2 
3 import asyncio
4 import logging
5 
6 import konnected
7 
8 from homeassistant.const import (
9  ATTR_ENTITY_ID,
10  ATTR_STATE,
11  CONF_ACCESS_TOKEN,
12  CONF_BINARY_SENSORS,
13  CONF_DEVICES,
14  CONF_DISCOVERY,
15  CONF_HOST,
16  CONF_ID,
17  CONF_NAME,
18  CONF_PIN,
19  CONF_PORT,
20  CONF_REPEAT,
21  CONF_SENSORS,
22  CONF_SWITCHES,
23  CONF_TYPE,
24  CONF_ZONE,
25 )
26 from homeassistant.core import callback
27 from homeassistant.helpers import aiohttp_client, device_registry as dr
28 from homeassistant.helpers.dispatcher import async_dispatcher_send
29 from homeassistant.helpers.event import async_call_later
30 from homeassistant.helpers.network import get_url
31 
32 from .const import (
33  CONF_ACTIVATION,
34  CONF_API_HOST,
35  CONF_BLINK,
36  CONF_DEFAULT_OPTIONS,
37  CONF_DHT_SENSORS,
38  CONF_DS18B20_SENSORS,
39  CONF_INVERSE,
40  CONF_MOMENTARY,
41  CONF_PAUSE,
42  CONF_POLL_INTERVAL,
43  DOMAIN,
44  ENDPOINT_ROOT,
45  STATE_LOW,
46  ZONE_TO_PIN,
47 )
48 from .errors import CannotConnect
49 
50 _LOGGER = logging.getLogger(__name__)
51 
52 KONN_MODEL = "Konnected"
53 KONN_MODEL_PRO = "Konnected Pro"
54 
55 # Indicate how each unit is controlled (pin or zone)
56 KONN_API_VERSIONS = {
57  KONN_MODEL: CONF_PIN,
58  KONN_MODEL_PRO: CONF_ZONE,
59 }
60 
61 
62 class AlarmPanel:
63  """A representation of a Konnected alarm panel."""
64 
65  def __init__(self, hass, config_entry):
66  """Initialize the Konnected device."""
67  self.hasshass = hass
68  self.config_entryconfig_entry = config_entry
69  self.configconfig = config_entry.data
70  self.optionsoptions = config_entry.options or config_entry.data.get(
71  CONF_DEFAULT_OPTIONS, {}
72  )
73  self.hosthost = self.configconfig.get(CONF_HOST)
74  self.portport = self.configconfig.get(CONF_PORT)
75  self.clientclient = None
76  self.statusstatus = None
77  self.api_versionapi_version = KONN_API_VERSIONS[KONN_MODEL]
78  self.connectedconnected = False
79  self.connect_attemptsconnect_attempts = 0
80  self.cancel_connect_retrycancel_connect_retry = None
81 
82  @property
83  def device_id(self):
84  """Device id is the chipId (pro) or MAC address as string with punctuation removed."""
85  return self.configconfig.get(CONF_ID)
86 
87  @property
89  """Return the configuration stored in `hass.data` for this device."""
90  return self.hasshass.data[DOMAIN][CONF_DEVICES].get(self.device_iddevice_id)
91 
92  @property
93  def available(self):
94  """Return whether the device is available."""
95  return self.connectedconnected
96 
97  def format_zone(self, zone, other_items=None):
98  """Get zone or pin based dict based on the client type."""
99  payload = {
100  self.api_versionapi_version: zone
101  if self.api_versionapi_version == CONF_ZONE
102  else ZONE_TO_PIN[zone]
103  }
104  payload.update(other_items or {})
105  return payload
106 
107  async def async_connect(self, now=None):
108  """Connect to and setup a Konnected device."""
109  if self.connectedconnected:
110  return
111 
112  if self.cancel_connect_retrycancel_connect_retry:
113  # cancel any pending connect attempt and try now
114  self.cancel_connect_retrycancel_connect_retry()
115 
116  try:
117  self.clientclient = konnected.Client(
118  host=self.hosthost,
119  port=str(self.portport),
120  websession=aiohttp_client.async_get_clientsession(self.hasshass),
121  )
122  self.statusstatus = await self.clientclient.get_status()
123  self.api_versionapi_version = KONN_API_VERSIONS.get(
124  self.statusstatus.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL]
125  )
126  _LOGGER.debug(
127  "Connected to new %s device", self.statusstatus.get("model", "Konnected")
128  )
129  _LOGGER.debug(self.statusstatus)
130 
131  await self.async_update_initial_statesasync_update_initial_states()
132  # brief delay to allow processing of recent status req
133  await asyncio.sleep(0.1)
134  await self.async_sync_device_configasync_sync_device_config()
135 
136  except self.clientclient.ClientError as err:
137  _LOGGER.warning("Exception trying to connect to panel: %s", err)
138 
139  # retry in a bit, never more than ~3 min
140  self.connect_attemptsconnect_attempts += 1
141  self.cancel_connect_retrycancel_connect_retry = async_call_later(
142  self.hasshass, 2 ** min(self.connect_attemptsconnect_attempts, 5) * 5, self.async_connectasync_connect
143  )
144  return
145 
146  self.connect_attemptsconnect_attempts = 0
147  self.connectedconnected = True
148  _LOGGER.debug(
149  (
150  "Set up Konnected device %s. Open http://%s:%s in a "
151  "web browser to view device status"
152  ),
153  self.device_iddevice_id,
154  self.hosthost,
155  self.portport,
156  )
157 
158  device_registry = dr.async_get(self.hasshass)
159  device_registry.async_get_or_create(
160  config_entry_id=self.config_entryconfig_entry.entry_id,
161  connections={(dr.CONNECTION_NETWORK_MAC, self.statusstatus.get("mac"))},
162  identifiers={(DOMAIN, self.device_iddevice_id)},
163  manufacturer="Konnected.io",
164  name=self.config_entryconfig_entry.title,
165  model=self.config_entryconfig_entry.title,
166  sw_version=self.statusstatus.get("swVersion"),
167  )
168 
169  async def update_switch(self, zone, state, momentary=None, times=None, pause=None):
170  """Update the state of a switchable output."""
171  try:
172  if self.clientclient:
173  if self.api_versionapi_version == CONF_ZONE:
174  return await self.clientclient.put_zone(
175  zone,
176  state,
177  momentary,
178  times,
179  pause,
180  )
181 
182  # device endpoint uses pin number instead of zone
183  return await self.clientclient.put_device(
184  ZONE_TO_PIN[zone],
185  state,
186  momentary,
187  times,
188  pause,
189  )
190 
191  except self.clientclient.ClientError as err:
192  _LOGGER.warning("Exception trying to update panel: %s", err)
193 
194  raise CannotConnect
195 
196  async def async_save_data(self):
197  """Save the device configuration to `hass.data`."""
198  binary_sensors = {}
199  for entity in self.optionsoptions.get(CONF_BINARY_SENSORS) or []:
200  zone = entity[CONF_ZONE]
201 
202  binary_sensors[zone] = {
203  CONF_TYPE: entity[CONF_TYPE],
204  CONF_NAME: entity.get(
205  CONF_NAME, f"Konnected {self.device_id[6:]} Zone {zone}"
206  ),
207  CONF_INVERSE: entity.get(CONF_INVERSE),
208  ATTR_STATE: None,
209  }
210  _LOGGER.debug(
211  "Set up binary_sensor %s (initial state: %s)",
212  binary_sensors[zone].get("name"),
213  binary_sensors[zone].get(ATTR_STATE),
214  )
215 
216  actuators = []
217  for entity in self.optionsoptions.get(CONF_SWITCHES) or []:
218  zone = entity[CONF_ZONE]
219 
220  act = {
221  CONF_ZONE: zone,
222  CONF_NAME: entity.get(
223  CONF_NAME,
224  f"Konnected {self.device_id[6:]} Actuator {zone}",
225  ),
226  ATTR_STATE: None,
227  CONF_ACTIVATION: entity[CONF_ACTIVATION],
228  CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
229  CONF_PAUSE: entity.get(CONF_PAUSE),
230  CONF_REPEAT: entity.get(CONF_REPEAT),
231  }
232  actuators.append(act)
233  _LOGGER.debug("Set up switch %s", act)
234 
235  sensors = []
236  for entity in self.optionsoptions.get(CONF_SENSORS) or []:
237  zone = entity[CONF_ZONE]
238 
239  sensor = {
240  CONF_ZONE: zone,
241  CONF_NAME: entity.get(
242  CONF_NAME, f"Konnected {self.device_id[6:]} Sensor {zone}"
243  ),
244  CONF_TYPE: entity[CONF_TYPE],
245  CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL),
246  }
247  sensors.append(sensor)
248  _LOGGER.debug(
249  "Set up %s sensor %s (initial state: %s)",
250  sensor.get(CONF_TYPE),
251  sensor.get(CONF_NAME),
252  sensor.get(ATTR_STATE),
253  )
254 
255  device_data = {
256  CONF_BINARY_SENSORS: binary_sensors,
257  CONF_SENSORS: sensors,
258  CONF_SWITCHES: actuators,
259  CONF_BLINK: self.optionsoptions.get(CONF_BLINK),
260  CONF_DISCOVERY: self.optionsoptions.get(CONF_DISCOVERY),
261  CONF_HOST: self.hosthost,
262  CONF_PORT: self.portport,
263  "panel": self,
264  }
265 
266  if CONF_DEVICES not in self.hasshass.data[DOMAIN]:
267  self.hasshass.data[DOMAIN][CONF_DEVICES] = {}
268 
269  _LOGGER.debug(
270  "Storing data in hass.data[%s][%s][%s]: %s",
271  DOMAIN,
272  CONF_DEVICES,
273  self.device_iddevice_id,
274  device_data,
275  )
276  self.hasshass.data[DOMAIN][CONF_DEVICES][self.device_iddevice_id] = device_data
277 
278  @callback
280  """Return the configuration map for syncing binary sensors."""
281  return [
282  self.format_zoneformat_zone(p) for p in self.stored_configurationstored_configuration[CONF_BINARY_SENSORS]
283  ]
284 
285  @callback
287  """Return the configuration map for syncing actuators."""
288  return [
289  self.format_zoneformat_zone(
290  data[CONF_ZONE],
291  {"trigger": (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] else 1)},
292  )
293  for data in self.stored_configurationstored_configuration[CONF_SWITCHES]
294  ]
295 
296  @callback
298  """Return the configuration map for syncing DHT sensors."""
299  return [
300  self.format_zoneformat_zone(
301  sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
302  )
303  for sensor in self.stored_configurationstored_configuration[CONF_SENSORS]
304  if sensor[CONF_TYPE] == "dht"
305  ]
306 
307  @callback
309  """Return the configuration map for syncing DS18B20 sensors."""
310  return [
311  self.format_zoneformat_zone(
312  sensor[CONF_ZONE], {CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]}
313  )
314  for sensor in self.stored_configurationstored_configuration[CONF_SENSORS]
315  if sensor[CONF_TYPE] == "ds18b20"
316  ]
317 
319  """Update the initial state of each sensor from status poll."""
320  for sensor_data in self.statusstatus.get("sensors"):
321  sensor_config = self.stored_configurationstored_configuration[CONF_BINARY_SENSORS].get(
322  sensor_data.get(CONF_ZONE, sensor_data.get(CONF_PIN)), {}
323  )
324  entity_id = sensor_config.get(ATTR_ENTITY_ID)
325 
326  state = bool(sensor_data.get(ATTR_STATE))
327  if sensor_config.get(CONF_INVERSE):
328  state = not state
329 
330  async_dispatcher_send(self.hasshass, f"konnected.{entity_id}.update", state)
331 
332  @callback
334  """Return a dict representing the desired device configuration."""
335  # keeping self.hass.data check for backwards compatibility
336  # newly configured integrations store this in the config entry
337  desired_api_host = self.optionsoptions.get(CONF_API_HOST) or (
338  self.hasshass.data[DOMAIN].get(CONF_API_HOST) or get_url(self.hasshass)
339  )
340  desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
341 
342  return {
343  "sensors": self.async_binary_sensor_configurationasync_binary_sensor_configuration(),
344  "actuators": self.async_actuator_configurationasync_actuator_configuration(),
345  "dht_sensors": self.async_dht_sensor_configurationasync_dht_sensor_configuration(),
346  "ds18b20_sensors": self.async_ds18b20_sensor_configurationasync_ds18b20_sensor_configuration(),
347  "auth_token": self.configconfig.get(CONF_ACCESS_TOKEN),
348  "endpoint": desired_api_endpoint,
349  "blink": self.optionsoptions.get(CONF_BLINK, True),
350  "discovery": self.optionsoptions.get(CONF_DISCOVERY, True),
351  }
352 
353  @callback
355  """Return a dict of configuration currently stored on the device."""
356  settings = self.statusstatus["settings"] or {}
357 
358  return {
359  "sensors": [
360  {self.api_versionapi_version: s[self.api_versionapi_version]}
361  for s in self.statusstatus.get("sensors")
362  ],
363  "actuators": self.statusstatus.get("actuators"),
364  "dht_sensors": self.statusstatus.get(CONF_DHT_SENSORS),
365  "ds18b20_sensors": self.statusstatus.get(CONF_DS18B20_SENSORS),
366  "auth_token": settings.get("token"),
367  "endpoint": settings.get("endpoint"),
368  "blink": settings.get(CONF_BLINK),
369  "discovery": settings.get(CONF_DISCOVERY),
370  }
371 
372  async def async_sync_device_config(self):
373  """Sync the new zone configuration to the Konnected device if needed."""
374  _LOGGER.debug(
375  "Device %s settings payload: %s",
376  self.device_iddevice_id,
377  self.async_desired_settings_payloadasync_desired_settings_payload(),
378  )
379  if (
380  self.async_desired_settings_payloadasync_desired_settings_payload()
381  != self.async_current_settings_payloadasync_current_settings_payload()
382  ):
383  _LOGGER.debug("Pushing settings to device %s", self.device_iddevice_id)
384  await self.clientclient.put_settings(**self.async_desired_settings_payloadasync_desired_settings_payload())
385 
386 
387 async def get_status(hass, host, port):
388  """Get the status of a Konnected Panel."""
389  client = konnected.Client(
390  host, str(port), aiohttp_client.async_get_clientsession(hass)
391  )
392  try:
393  return await client.get_status()
394 
395  except client.ClientError as err:
396  _LOGGER.error("Exception trying to get panel status: %s", err)
397  raise CannotConnect from err
def __init__(self, hass, config_entry)
Definition: panel.py:65
def format_zone(self, zone, other_items=None)
Definition: panel.py:97
def update_switch(self, zone, state, momentary=None, times=None, pause=None)
Definition: panel.py:169
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
def get_status(hass, host, port)
Definition: panel.py:387
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
Definition: network.py:131