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
This commit is contained in:
Ben 2020-01-06 15:00:01 +01:00 committed by Charles Garwood
parent 0971c681af
commit 1fffa210e1
8 changed files with 513 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"]
}

View file

@ -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()

View file

@ -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