From 1fffa210e1617b516d8ac10fe2c59e8838391935 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 6 Jan 2020 15:00:01 +0100 Subject: [PATCH] Add surepetcare component (#24426) * add surepetcare * cleanup * remove unused imports and comments * remove comment * fix bug which prevented updating the sensors * improve config validation * fix voluptuous usage * fix format & credential storage * various fixes to hass-conform * small format fixes * change False to None * still trying to be hass-conform * remove unused class * fix imports * fix f-string * add guard clause?! * central data fetch * do not pass in hass, will be provided automatically * make the linters happy * disable constant-test warning and add commas * worksforme * fix link in manifest * remove icon * bump surepy to 0.1.5 * worksforme * small doc fixes * add discovery_info guard * result of another awesome review * and again :) * exclude surepetcare in .coveragerc --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/surepetcare/__init__.py | 163 ++++++++++++++++ .../components/surepetcare/binary_sensor.py | 176 ++++++++++++++++++ homeassistant/components/surepetcare/const.py | 27 +++ .../components/surepetcare/manifest.json | 8 + .../components/surepetcare/sensor.py | 134 +++++++++++++ requirements_all.txt | 3 + 8 files changed, 513 insertions(+) create mode 100644 homeassistant/components/surepetcare/__init__.py create mode 100644 homeassistant/components/surepetcare/binary_sensor.py create mode 100644 homeassistant/components/surepetcare/const.py create mode 100644 homeassistant/components/surepetcare/manifest.json create mode 100644 homeassistant/components/surepetcare/sensor.py diff --git a/.coveragerc b/.coveragerc index 71989964c6d..4d4d962a9b3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -665,6 +665,7 @@ omit = homeassistant/components/streamlabswater/* homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py + homeassistant/components/surepetcare/*.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index 13c24bcbfd4..7940b5878ca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -314,6 +314,7 @@ homeassistant/components/stt/* @pvizeli homeassistant/components/suez_water/* @ooii homeassistant/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek +homeassistant/components/surepetcare/* @benleb homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff homeassistant/components/switchbot/* @danielhiversen diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py new file mode 100644 index 00000000000..450d7eb9a15 --- /dev/null +++ b/homeassistant/components/surepetcare/__init__.py @@ -0,0 +1,163 @@ +"""Support for Sure Petcare cat/pet flaps.""" +import logging + +from surepy import ( + SurePetcare, + SurePetcareAuthenticationError, + SurePetcareError, + SureThingID, +) +import voluptuous as vol + +from homeassistant.const import ( + CONF_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TYPE, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_FLAPS, + CONF_HOUSEHOLD_ID, + CONF_PETS, + DATA_SURE_PETCARE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + SPC, + TOPIC_UPDATE, +) + +_LOGGER = logging.getLogger(__name__) + + +FLAP_SCHEMA = vol.Schema( + {vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string} +) + +PET_SCHEMA = vol.Schema( + {vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string} +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_HOUSEHOLD_ID): cv.positive_int, + vol.Required(CONF_FLAPS): vol.All(cv.ensure_list, [FLAP_SCHEMA]), + vol.Required(CONF_PETS): vol.All(cv.ensure_list, [PET_SCHEMA]), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Initialize the Sure Petcare component.""" + conf = config[DOMAIN] + + # update interval + scan_interval = conf[CONF_SCAN_INTERVAL] + + # shared data + hass.data[DOMAIN] = hass.data[DATA_SURE_PETCARE] = {} + + # sure petcare api connection + try: + surepy = SurePetcare( + conf[CONF_USERNAME], + conf[CONF_PASSWORD], + conf[CONF_HOUSEHOLD_ID], + hass.loop, + async_get_clientsession(hass), + ) + await surepy.refresh_token() + except SurePetcareAuthenticationError: + _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") + return False + except SurePetcareError as error: + _LOGGER.error("Unable to connect to surepetcare.io: Wrong %s!", error) + return False + + # add flaps + things = [ + { + CONF_NAME: flap[CONF_NAME], + CONF_ID: flap[CONF_ID], + CONF_TYPE: SureThingID.FLAP.name, + } + for flap in conf[CONF_FLAPS] + ] + + # add pets + things.extend( + [ + { + CONF_NAME: pet[CONF_NAME], + CONF_ID: pet[CONF_ID], + CONF_TYPE: SureThingID.PET.name, + } + for pet in conf[CONF_PETS] + ] + ) + + spc = hass.data[DATA_SURE_PETCARE][SPC] = SurePetcareAPI( + hass, surepy, things, conf[CONF_HOUSEHOLD_ID] + ) + + # initial update + await spc.async_update() + + async_track_time_interval(hass, spc.async_update, scan_interval) + + # load platforms + hass.async_create_task( + hass.helpers.discovery.async_load_platform("binary_sensor", DOMAIN, {}, config) + ) + hass.async_create_task( + hass.helpers.discovery.async_load_platform("sensor", DOMAIN, {}, config) + ) + + return True + + +class SurePetcareAPI: + """Define a generic Sure Petcare object.""" + + def __init__(self, hass, surepy, ids, household_id): + """Initialize the Sure Petcare object.""" + self.hass = hass + self.surepy = surepy + self.household_id = household_id + self.ids = ids + self.states = {} + + async def async_update(self, args=None): + """Refresh Sure Petcare data.""" + for thing in self.ids: + sure_id = thing[CONF_ID] + sure_type = thing[CONF_TYPE] + + try: + type_state = self.states.setdefault(sure_type, {}) + + if sure_type == SureThingID.FLAP.name: + type_state[sure_id] = await self.surepy.get_flap_data(sure_id) + elif sure_type == SureThingID.PET.name: + type_state[sure_id] = await self.surepy.get_pet_data(sure_id) + + except SurePetcareError as error: + _LOGGER.error("Unable to retrieve data from surepetcare.io: %s", error) + + async_dispatcher_send(self.hass, TOPIC_UPDATE) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py new file mode 100644 index 00000000000..100da5cb790 --- /dev/null +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -0,0 +1,176 @@ +"""Support for Sure PetCare Flaps/Pets binary sensors.""" +import logging + +from surepy import SureLocationID, SureLockStateID, SureThingID + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_LOCK, + DEVICE_CLASS_PRESENCE, + BinarySensorDevice, +) +from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DATA_SURE_PETCARE, DEFAULT_DEVICE_CLASS, SPC, TOPIC_UPDATE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Sure PetCare Flaps sensors based on a config entry.""" + if discovery_info is None: + return + + entities = [] + + spc = hass.data[DATA_SURE_PETCARE][SPC] + + for thing in spc.ids: + sure_id = thing[CONF_ID] + sure_type = thing[CONF_TYPE] + + if sure_type == SureThingID.FLAP.name: + entity = Flap(sure_id, thing[CONF_NAME], spc) + elif sure_type == SureThingID.PET.name: + entity = Pet(sure_id, thing[CONF_NAME], spc) + + entities.append(entity) + + async_add_entities(entities, True) + + +class SurePetcareBinarySensor(BinarySensorDevice): + """A binary sensor implementation for Sure Petcare Entities.""" + + def __init__( + self, _id: int, name: str, spc, device_class: str, sure_type: SureThingID + ): + """Initialize a Sure Petcare binary sensor.""" + self._id = _id + self._name = name + self._spc = spc + self._device_class = device_class + self._sure_type = sure_type + self._state = {} + + self._async_unsub_dispatcher_connect = None + + @property + def is_on(self): + """Return true if entity is on/unlocked.""" + return bool(self._state) + + @property + def should_poll(self): + """Return true.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state + + @property + def device_class(self): + """Return the device class.""" + return DEFAULT_DEVICE_CLASS if not self._device_class else self._device_class + + @property + def unique_id(self): + """Return an unique ID.""" + return f"{self._spc.household_id}-{self._id}" + + async def async_update(self): + """Get the latest data and update the state.""" + self._state = self._spc.states[self._sure_type][self._id].get("data") + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update + ) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + +class Flap(SurePetcareBinarySensor): + """Sure Petcare Flap.""" + + def __init__(self, _id: int, name: str, spc): + """Initialize a Sure Petcare Flap.""" + super().__init__( + _id, + f"Flap {name.capitalize()}", + spc, + DEVICE_CLASS_LOCK, + SureThingID.FLAP.name, + ) + + @property + def is_on(self): + """Return true if entity is on/unlocked.""" + try: + return bool(self._state["locking"]["mode"] == SureLockStateID.UNLOCKED) + except (KeyError, TypeError): + return None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attributes = None + if self._state: + try: + attributes = { + "battery_voltage": self._state["battery"] / 4, + "locking_mode": self._state["locking"]["mode"], + "device_rssi": self._state["signal"]["device_rssi"], + "hub_rssi": self._state["signal"]["hub_rssi"], + } + + except (KeyError, TypeError) as error: + _LOGGER.error( + "Error getting device state attributes from %s: %s\n\n%s", + self._name, + error, + self._state, + ) + attributes = self._state + + return attributes + + +class Pet(SurePetcareBinarySensor): + """Sure Petcare Pet.""" + + def __init__(self, _id: int, name: str, spc): + """Initialize a Sure Petcare Pet.""" + super().__init__( + _id, + f"Pet {name.capitalize()}", + spc, + DEVICE_CLASS_PRESENCE, + SureThingID.PET.name, + ) + + @property + def is_on(self): + """Return true if entity is at home.""" + try: + return bool(self._state["where"] == SureLocationID.INSIDE) + except (KeyError, TypeError): + return False diff --git a/homeassistant/components/surepetcare/const.py b/homeassistant/components/surepetcare/const.py new file mode 100644 index 00000000000..731bfba07e6 --- /dev/null +++ b/homeassistant/components/surepetcare/const.py @@ -0,0 +1,27 @@ +"""Constants for the Sure Petcare component.""" +from datetime import timedelta + +DOMAIN = "surepetcare" +DEFAULT_DEVICE_CLASS = "lock" +DEFAULT_ICON = "mdi:cat" +DEFAULT_SCAN_INTERVAL = timedelta(minutes=3) + +DATA_SURE_PETCARE = f"data_{DOMAIN}" +SPC = "spc" +SUREPY = "surepy" + +CONF_HOUSEHOLD_ID = "household_id" +CONF_FLAPS = "flaps" +CONF_PETS = "pets" +CONF_DATA = "data" + +SURE_IDS = "sure_ids" + +# platforms +TOPIC_UPDATE = f"{DOMAIN}_data_update" + +# flap +BATTERY_ICON = "mdi:battery" +SURE_BATT_VOLTAGE_FULL = 1.6 # voltage +SURE_BATT_VOLTAGE_LOW = 1.25 # voltage +SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json new file mode 100644 index 00000000000..6da8b772444 --- /dev/null +++ b/homeassistant/components/surepetcare/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "surepetcare", + "name": "Sure Petcare", + "documentation": "https://www.home-assistant.io/integrations/surepetcare", + "dependencies": [], + "codeowners": ["@benleb"], + "requirements": ["surepy==0.1.10"] + } \ No newline at end of file diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py new file mode 100644 index 00000000000..dd7fdcb0316 --- /dev/null +++ b/homeassistant/components/surepetcare/sensor.py @@ -0,0 +1,134 @@ +"""Support for Sure PetCare Flaps/Pets sensors.""" +import logging + +from surepy import SureThingID + +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ID, + CONF_NAME, + CONF_TYPE, + DEVICE_CLASS_BATTERY, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import ( + DATA_SURE_PETCARE, + SPC, + SURE_BATT_VOLTAGE_DIFF, + SURE_BATT_VOLTAGE_LOW, + TOPIC_UPDATE, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Sure PetCare Flaps sensors.""" + if discovery_info is None: + return + + spc = hass.data[DATA_SURE_PETCARE][SPC] + async_add_entities( + [ + FlapBattery(entity[CONF_ID], entity[CONF_NAME], spc) + for entity in spc.ids + if entity[CONF_TYPE] == SureThingID.FLAP.name + ], + True, + ) + + +class FlapBattery(Entity): + """Sure Petcare Flap.""" + + def __init__(self, _id: int, name: str, spc): + """Initialize a Sure Petcare Flap battery sensor.""" + self._id = _id + self._name = f"Flap {name.capitalize()} Battery Level" + self._spc = spc + self._state = self._spc.states[SureThingID.FLAP.name][self._id].get("data") + + self._async_unsub_dispatcher_connect = None + + @property + def should_poll(self): + """Return true.""" + return False + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def state(self): + """Return battery level in percent.""" + try: + per_battery_voltage = self._state["battery"] / 4 + voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW + battery_percent = int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100) + except (KeyError, TypeError): + battery_percent = None + + return battery_percent + + @property + def unique_id(self): + """Return an unique ID.""" + return f"{self._spc.household_id}-{self._id}" + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_BATTERY + + @property + def device_state_attributes(self): + """Return state attributes.""" + attributes = None + if self._state: + try: + voltage_per_battery = float(self._state["battery"]) / 4 + attributes = { + ATTR_VOLTAGE: f"{float(self._state['battery']):.2f}", + f"{ATTR_VOLTAGE}_per_battery": f"{voltage_per_battery:.2f}", + } + except (KeyError, TypeError) as error: + attributes = self._state + _LOGGER.error( + "Error getting device state attributes from %s: %s\n\n%s", + self._name, + error, + self._state, + ) + + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + async def async_update(self): + """Get the latest data and update the state.""" + self._state = self._spc.states[SureThingID.FLAP.name][self._id].get("data") + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update + ) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() diff --git a/requirements_all.txt b/requirements_all.txt index f7a95726601..0990f5848cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1914,6 +1914,9 @@ sucks==0.9.4 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.surepetcare +surepy==0.1.10 + # homeassistant.components.swiss_hydrological_data swisshydrodata==0.0.3