Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Konnected devices."""
2 
3 import copy
4 import hmac
5 from http import HTTPStatus
6 import json
7 import logging
8 
9 from aiohttp.hdrs import AUTHORIZATION
10 from aiohttp.web import Request, Response
11 import voluptuous as vol
12 
13 from homeassistant import config_entries
14 from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
15 from homeassistant.components.http import KEY_HASS, HomeAssistantView
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import (
18  ATTR_ENTITY_ID,
19  CONF_ACCESS_TOKEN,
20  CONF_BINARY_SENSORS,
21  CONF_DEVICES,
22  CONF_DISCOVERY,
23  CONF_HOST,
24  CONF_ID,
25  CONF_NAME,
26  CONF_PIN,
27  CONF_PORT,
28  CONF_REPEAT,
29  CONF_SENSORS,
30  CONF_SWITCHES,
31  CONF_TYPE,
32  CONF_ZONE,
33  STATE_OFF,
34  STATE_ON,
35  Platform,
36 )
37 from homeassistant.core import HomeAssistant
38 from homeassistant.helpers import config_validation as cv
39 from homeassistant.helpers.typing import ConfigType
40 
41 from .config_flow import ( # Loading the config flow file will register the flow
42  CONF_DEFAULT_OPTIONS,
43  CONF_IO,
44  CONF_IO_BIN,
45  CONF_IO_DIG,
46  CONF_IO_SWI,
47  OPTIONS_SCHEMA,
48 )
49 from .const import (
50  CONF_ACTIVATION,
51  CONF_API_HOST,
52  CONF_BLINK,
53  CONF_INVERSE,
54  CONF_MOMENTARY,
55  CONF_PAUSE,
56  CONF_POLL_INTERVAL,
57  DOMAIN,
58  PIN_TO_ZONE,
59  STATE_HIGH,
60  STATE_LOW,
61  UNDO_UPDATE_LISTENER,
62  UPDATE_ENDPOINT,
63  ZONE_TO_PIN,
64  ZONES,
65 )
66 from .handlers import HANDLERS
67 from .panel import AlarmPanel
68 
69 _LOGGER = logging.getLogger(__name__)
70 
71 
72 def ensure_pin(value):
73  """Check if valid pin and coerce to string."""
74  if value is None:
75  raise vol.Invalid("pin value is None")
76 
77  if PIN_TO_ZONE.get(str(value)) is None:
78  raise vol.Invalid("pin not valid")
79 
80  return str(value)
81 
82 
83 def ensure_zone(value):
84  """Check if valid zone and coerce to string."""
85  if value is None:
86  raise vol.Invalid("zone value is None")
87 
88  if str(value) not in ZONES:
89  raise vol.Invalid("zone not valid")
90 
91  return str(value)
92 
93 
95  """Validate zones and reformat for import."""
96  config = copy.deepcopy(config)
97  io_cfgs = {}
98  # Replace pins with zones
99  for conf_platform, conf_io in (
100  (CONF_BINARY_SENSORS, CONF_IO_BIN),
101  (CONF_SENSORS, CONF_IO_DIG),
102  (CONF_SWITCHES, CONF_IO_SWI),
103  ):
104  for zone in config.get(conf_platform, []):
105  if zone.get(CONF_PIN):
106  zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]]
107  del zone[CONF_PIN]
108  io_cfgs[zone[CONF_ZONE]] = conf_io
109 
110  # Migrate config_entry data into default_options structure
111  config[CONF_IO] = io_cfgs
112  config[CONF_DEFAULT_OPTIONS] = OPTIONS_SCHEMA(config)
113 
114  # clean up fields migrated to options
115  config.pop(CONF_BINARY_SENSORS, None)
116  config.pop(CONF_SENSORS, None)
117  config.pop(CONF_SWITCHES, None)
118  config.pop(CONF_BLINK, None)
119  config.pop(CONF_DISCOVERY, None)
120  config.pop(CONF_API_HOST, None)
121  config.pop(CONF_IO, None)
122  return config
123 
124 
125 def import_validator(config):
126  """Reformat for import."""
127  config = copy.deepcopy(config)
128 
129  # push api_host into device configs
130  for device in config.get(CONF_DEVICES, []):
131  device[CONF_API_HOST] = config.get(CONF_API_HOST, "")
132 
133  return config
134 
135 
136 # configuration.yaml schemas (legacy)
137 BINARY_SENSOR_SCHEMA_YAML = vol.All(
138  vol.Schema(
139  {
140  vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
141  vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
142  vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
143  vol.Optional(CONF_NAME): cv.string,
144  vol.Optional(CONF_INVERSE, default=False): cv.boolean,
145  }
146  ),
147  cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
148 )
149 
150 SENSOR_SCHEMA_YAML = vol.All(
151  vol.Schema(
152  {
153  vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
154  vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
155  vol.Required(CONF_TYPE): vol.All(vol.Lower, vol.In(["dht", "ds18b20"])),
156  vol.Optional(CONF_NAME): cv.string,
157  vol.Optional(CONF_POLL_INTERVAL, default=3): vol.All(
158  vol.Coerce(int), vol.Range(min=1)
159  ),
160  }
161  ),
162  cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
163 )
164 
165 SWITCH_SCHEMA_YAML = vol.All(
166  vol.Schema(
167  {
168  vol.Exclusive(CONF_ZONE, "s_io"): ensure_zone,
169  vol.Exclusive(CONF_PIN, "s_io"): ensure_pin,
170  vol.Optional(CONF_NAME): cv.string,
171  vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All(
172  vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)
173  ),
174  vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)),
175  vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)),
176  vol.Optional(CONF_REPEAT): vol.All(vol.Coerce(int), vol.Range(min=-1)),
177  }
178  ),
179  cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
180 )
181 
182 DEVICE_SCHEMA_YAML = vol.All(
183  vol.Schema(
184  {
185  vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
186  vol.Optional(CONF_BINARY_SENSORS): vol.All(
187  cv.ensure_list, [BINARY_SENSOR_SCHEMA_YAML]
188  ),
189  vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA_YAML]),
190  vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA_YAML]),
191  vol.Inclusive(CONF_HOST, "host_info"): cv.string,
192  vol.Inclusive(CONF_PORT, "host_info"): cv.port,
193  vol.Optional(CONF_BLINK, default=True): cv.boolean,
194  vol.Optional(CONF_API_HOST, default=""): vol.Any("", cv.url),
195  vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
196  }
197  ),
198  import_device_validator,
199 )
200 
201 CONFIG_SCHEMA = vol.Schema(
202  {
203  DOMAIN: vol.All(
204  import_validator,
205  vol.Schema(
206  {
207  vol.Required(CONF_ACCESS_TOKEN): cv.string,
208  vol.Optional(CONF_API_HOST): vol.Url(),
209  vol.Optional(CONF_DEVICES): vol.All(
210  cv.ensure_list, [DEVICE_SCHEMA_YAML]
211  ),
212  }
213  ),
214  )
215  },
216  extra=vol.ALLOW_EXTRA,
217 )
218 
219 YAML_CONFIGS = "yaml_configs"
220 PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
221 
222 
223 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
224  """Set up the Konnected platform."""
225  if (cfg := config.get(DOMAIN)) is None:
226  cfg = {}
227 
228  if DOMAIN not in hass.data:
229  hass.data[DOMAIN] = {
230  CONF_ACCESS_TOKEN: cfg.get(CONF_ACCESS_TOKEN),
231  CONF_API_HOST: cfg.get(CONF_API_HOST),
232  CONF_DEVICES: {},
233  }
234 
235  hass.http.register_view(KonnectedView)
236 
237  # Check if they have yaml configured devices
238  if CONF_DEVICES not in cfg:
239  return True
240 
241  for device in cfg.get(CONF_DEVICES, []):
242  # Attempt to importing the cfg. Use
243  # hass.async_add_job to avoid a deadlock.
244  hass.async_create_task(
245  hass.config_entries.flow.async_init(
246  DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=device
247  )
248  )
249  return True
250 
251 
252 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
253  """Set up panel from a config entry."""
254  client = AlarmPanel(hass, entry)
255  # creates a panel data store in hass.data[DOMAIN][CONF_DEVICES]
256  await client.async_save_data()
257 
258  # if the cfg entry was created we know we could connect to the panel at some point
259  # async_connect will handle retries until it establishes a connection
260  await client.async_connect()
261 
262  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
263 
264  # config entry specific data to enable unload
265  hass.data[DOMAIN][entry.entry_id] = {
266  UNDO_UPDATE_LISTENER: entry.add_update_listener(async_entry_updated)
267  }
268  return True
269 
270 
271 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
272  """Unload a config entry."""
273  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
274 
275  hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
276 
277  if unload_ok:
278  hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
279  hass.data[DOMAIN].pop(entry.entry_id)
280 
281  return unload_ok
282 
283 
284 async def async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
285  """Reload the config entry when options change."""
286  await hass.config_entries.async_reload(entry.entry_id)
287 
288 
289 class KonnectedView(HomeAssistantView):
290  """View creates an endpoint to receive push updates from the device."""
291 
292  url = UPDATE_ENDPOINT
293  name = "api:konnected"
294  requires_auth = False # Uses access token from configuration
295 
296  def __init__(self) -> None:
297  """Initialize the view."""
298 
299  @staticmethod
300  def binary_value(state, activation):
301  """Return binary value for GPIO based on state and activation."""
302  if activation == STATE_HIGH:
303  return 1 if state == STATE_ON else 0
304  return 0 if state == STATE_ON else 1
305 
306  async def update_sensor(self, request: Request, device_id) -> Response:
307  """Process a put or post."""
308  hass = request.app[KEY_HASS]
309  data = hass.data[DOMAIN]
310 
311  auth = request.headers.get(AUTHORIZATION)
312  tokens = []
313  if hass.data[DOMAIN].get(CONF_ACCESS_TOKEN):
314  tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]])
315  tokens.extend(
316  [
317  entry.data[CONF_ACCESS_TOKEN]
318  for entry in hass.config_entries.async_entries(DOMAIN)
319  if entry.data.get(CONF_ACCESS_TOKEN)
320  ]
321  )
322  if auth is None or not next(
323  (True for token in tokens if hmac.compare_digest(f"Bearer {token}", auth)),
324  False,
325  ):
326  return self.json_message(
327  "unauthorized", status_code=HTTPStatus.UNAUTHORIZED
328  )
329 
330  try: # Konnected 2.2.0 and above supports JSON payloads
331  payload = await request.json()
332  except json.decoder.JSONDecodeError:
333  _LOGGER.error(
334  "Your Konnected device software may be out of "
335  "date. Visit https://help.konnected.io for "
336  "updating instructions"
337  )
338 
339  if (device := data[CONF_DEVICES].get(device_id)) is None:
340  return self.json_message(
341  "unregistered device", status_code=HTTPStatus.BAD_REQUEST
342  )
343 
344  if (panel := device.get("panel")) is not None:
345  # connect if we haven't already
346  hass.async_create_task(panel.async_connect())
347 
348  try:
349  zone_num = str(payload.get(CONF_ZONE) or PIN_TO_ZONE[payload[CONF_PIN]])
350  payload[CONF_ZONE] = zone_num
351  zone_data = (
352  device[CONF_BINARY_SENSORS].get(zone_num)
353  or next(
354  (s for s in device[CONF_SWITCHES] if s[CONF_ZONE] == zone_num), None
355  )
356  or next(
357  (s for s in device[CONF_SENSORS] if s[CONF_ZONE] == zone_num), None
358  )
359  )
360  except KeyError:
361  zone_data = None
362 
363  if zone_data is None:
364  return self.json_message(
365  "unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST
366  )
367 
368  zone_data["device_id"] = device_id
369 
370  for attr in ("state", "temp", "humi", "addr"):
371  value = payload.get(attr)
372  handler = HANDLERS.get(attr)
373  if value is not None and handler:
374  hass.async_create_task(handler(hass, zone_data, payload))
375 
376  return self.json_message("ok")
377 
378  async def get(self, request: Request, device_id) -> Response:
379  """Return the current binary state of a switch."""
380  hass = request.app[KEY_HASS]
381  data = hass.data[DOMAIN]
382 
383  if not (device := data[CONF_DEVICES].get(device_id)):
384  return self.json_message(
385  f"Device {device_id} not configured", status_code=HTTPStatus.NOT_FOUND
386  )
387 
388  if (panel := device.get("panel")) is not None:
389  # connect if we haven't already
390  hass.async_create_task(panel.async_connect())
391 
392  # Our data model is based on zone ids but we convert from/to pin ids
393  # based on whether they are specified in the request
394  try:
395  zone_num = str(
396  request.query.get(CONF_ZONE) or PIN_TO_ZONE[request.query[CONF_PIN]]
397  )
398  zone = next(
399  switch
400  for switch in device[CONF_SWITCHES]
401  if switch[CONF_ZONE] == zone_num
402  )
403 
404  except StopIteration:
405  zone = None
406  except KeyError:
407  zone = None
408  zone_num = None
409 
410  if not zone:
411  target = request.query.get(
412  CONF_ZONE, request.query.get(CONF_PIN, "unknown")
413  )
414  return self.json_message(
415  f"Switch on zone or pin {target} not configured",
416  status_code=HTTPStatus.NOT_FOUND,
417  )
418 
419  resp = {}
420  if request.query.get(CONF_ZONE):
421  resp[CONF_ZONE] = zone_num
422  elif zone_num:
423  resp[CONF_PIN] = ZONE_TO_PIN[zone_num]
424 
425  # Make sure entity is setup
426  if zone_entity_id := zone.get(ATTR_ENTITY_ID):
427  resp["state"] = self.binary_valuebinary_value(
428  hass.states.get(zone_entity_id).state, # type: ignore[union-attr]
429  zone[CONF_ACTIVATION],
430  )
431  return self.json(resp)
432 
433  _LOGGER.warning("Konnected entity not yet setup, returning default")
434  resp["state"] = self.binary_valuebinary_value(STATE_OFF, zone[CONF_ACTIVATION])
435  return self.json(resp)
436 
437  async def put(self, request: Request, device_id) -> Response:
438  """Receive a sensor update via PUT request and async set state."""
439  return await self.update_sensorupdate_sensor(request, device_id)
440 
441  async def post(self, request: Request, device_id) -> Response:
442  """Receive a sensor update via POST request and async set state."""
443  return await self.update_sensorupdate_sensor(request, device_id)
Response put(self, Request request, device_id)
Definition: __init__.py:437
Response post(self, Request request, device_id)
Definition: __init__.py:441
Response update_sensor(self, Request request, device_id)
Definition: __init__.py:306
Response get(self, Request request, device_id)
Definition: __init__.py:378
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:252
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:271
None async_entry_updated(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:284
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:223