1 """Support for Konnected devices."""
5 from http
import HTTPStatus
9 from aiohttp.hdrs
import AUTHORIZATION
10 from aiohttp.web
import Request, Response
11 import voluptuous
as vol
13 from homeassistant
import config_entries
41 from .config_flow
import (
66 from .handlers
import HANDLERS
67 from .panel
import AlarmPanel
69 _LOGGER = logging.getLogger(__name__)
73 """Check if valid pin and coerce to string."""
75 raise vol.Invalid(
"pin value is None")
77 if PIN_TO_ZONE.get(
str(value))
is None:
78 raise vol.Invalid(
"pin not valid")
84 """Check if valid zone and coerce to string."""
86 raise vol.Invalid(
"zone value is None")
88 if str(value)
not in ZONES:
89 raise vol.Invalid(
"zone not valid")
95 """Validate zones and reformat for import."""
96 config = copy.deepcopy(config)
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),
104 for zone
in config.get(conf_platform, []):
105 if zone.get(CONF_PIN):
106 zone[CONF_ZONE] = PIN_TO_ZONE[zone[CONF_PIN]]
108 io_cfgs[zone[CONF_ZONE]] = conf_io
111 config[CONF_IO] = io_cfgs
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)
126 """Reformat for import."""
127 config = copy.deepcopy(config)
130 for device
in config.get(CONF_DEVICES, []):
131 device[CONF_API_HOST] = config.get(CONF_API_HOST,
"")
137 BINARY_SENSOR_SCHEMA_YAML = vol.All(
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,
147 cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
150 SENSOR_SCHEMA_YAML = vol.All(
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)
162 cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
165 SWITCH_SCHEMA_YAML = vol.All(
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)
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)),
179 cv.has_at_least_one_key(CONF_PIN, CONF_ZONE),
182 DEVICE_SCHEMA_YAML = vol.All(
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]
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,
198 import_device_validator,
201 CONFIG_SCHEMA = vol.Schema(
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]
216 extra=vol.ALLOW_EXTRA,
219 YAML_CONFIGS =
"yaml_configs"
220 PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
223 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
224 """Set up the Konnected platform."""
225 if (cfg := config.get(DOMAIN))
is None:
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),
235 hass.http.register_view(KonnectedView)
238 if CONF_DEVICES
not in cfg:
241 for device
in cfg.get(CONF_DEVICES, []):
244 hass.async_create_task(
245 hass.config_entries.flow.async_init(
246 DOMAIN, context={
"source": config_entries.SOURCE_IMPORT}, data=device
253 """Set up panel from a config entry."""
256 await client.async_save_data()
260 await client.async_connect()
262 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
265 hass.data[DOMAIN][entry.entry_id] = {
266 UNDO_UPDATE_LISTENER: entry.add_update_listener(async_entry_updated)
272 """Unload a config entry."""
273 unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
275 hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]()
278 hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID])
279 hass.data[DOMAIN].pop(entry.entry_id)
285 """Reload the config entry when options change."""
286 await hass.config_entries.async_reload(entry.entry_id)
290 """View creates an endpoint to receive push updates from the device."""
292 url = UPDATE_ENDPOINT
293 name =
"api:konnected"
294 requires_auth =
False
297 """Initialize the view."""
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
307 """Process a put or post."""
308 hass = request.app[KEY_HASS]
309 data = hass.data[DOMAIN]
311 auth = request.headers.get(AUTHORIZATION)
313 if hass.data[DOMAIN].
get(CONF_ACCESS_TOKEN):
314 tokens.extend([hass.data[DOMAIN][CONF_ACCESS_TOKEN]])
317 entry.data[CONF_ACCESS_TOKEN]
318 for entry
in hass.config_entries.async_entries(DOMAIN)
319 if entry.data.get(CONF_ACCESS_TOKEN)
322 if auth
is None or not next(
323 (
True for token
in tokens
if hmac.compare_digest(f
"Bearer {token}", auth)),
326 return self.json_message(
327 "unauthorized", status_code=HTTPStatus.UNAUTHORIZED
331 payload = await request.json()
332 except json.decoder.JSONDecodeError:
334 "Your Konnected device software may be out of "
335 "date. Visit https://help.konnected.io for "
336 "updating instructions"
339 if (device := data[CONF_DEVICES].
get(device_id))
is None:
340 return self.json_message(
341 "unregistered device", status_code=HTTPStatus.BAD_REQUEST
344 if (panel := device.get(
"panel"))
is not None:
346 hass.async_create_task(panel.async_connect())
349 zone_num =
str(payload.get(CONF_ZONE)
or PIN_TO_ZONE[payload[CONF_PIN]])
350 payload[CONF_ZONE] = zone_num
352 device[CONF_BINARY_SENSORS].
get(zone_num)
354 (s
for s
in device[CONF_SWITCHES]
if s[CONF_ZONE] == zone_num),
None
357 (s
for s
in device[CONF_SENSORS]
if s[CONF_ZONE] == zone_num),
None
363 if zone_data
is None:
364 return self.json_message(
365 "unregistered sensor/actuator", status_code=HTTPStatus.BAD_REQUEST
368 zone_data[
"device_id"] = device_id
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))
376 return self.json_message(
"ok")
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]
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
388 if (panel := device.get(
"panel"))
is not None:
390 hass.async_create_task(panel.async_connect())
396 request.query.get(CONF_ZONE)
or PIN_TO_ZONE[request.query[CONF_PIN]]
400 for switch
in device[CONF_SWITCHES]
401 if switch[CONF_ZONE] == zone_num
404 except StopIteration:
411 target = request.query.get(
412 CONF_ZONE, request.query.get(CONF_PIN,
"unknown")
414 return self.json_message(
415 f
"Switch on zone or pin {target} not configured",
416 status_code=HTTPStatus.NOT_FOUND,
420 if request.query.get(CONF_ZONE):
421 resp[CONF_ZONE] = zone_num
423 resp[CONF_PIN] = ZONE_TO_PIN[zone_num]
426 if zone_entity_id := zone.get(ATTR_ENTITY_ID):
428 hass.states.get(zone_entity_id).state,
429 zone[CONF_ACTIVATION],
431 return self.json(resp)
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)
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)
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)
Response post(self, Request request, device_id)
Response update_sensor(self, Request request, device_id)
def binary_value(state, activation)
Response get(self, Request request, device_id)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
def import_validator(config)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
def import_device_validator(config)
None async_entry_updated(HomeAssistant hass, ConfigEntry entry)
bool async_setup(HomeAssistant hass, ConfigType config)