From 9487bd455a3aa3bc4a4d72e5486e1360e7f5a651 Mon Sep 17 00:00:00 2001 From: Heiko Thiery <heiko.thiery@gmail.com> Date: Tue, 17 Apr 2018 12:40:36 +0200 Subject: [PATCH] Add AVM fritzbox smarthome component (#10688) * initial commit Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix failed flake8 tests Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add fritzhome files to .coveragerc Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix wrong module import Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove too general exception Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * incorporate review comments Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove blank line Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix wrong import Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix issue with operations Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * incorporate review comments Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove unused attributes Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * adapt to supported_features Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * change checking of kwargs to canonical way Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove unused self._state Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Don't overwrite the platform domain Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Remove parenthesis from import without line break Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Do not pass hass to the components on init Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Remove check for available in current_operation Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Remove redundant logging message Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Add blank line between standard and hass imports Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Use states from base climate component Also add the new state STATE_MANUAL to the base. Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add reconnect when access failed Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add device specific attributes Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * group the imports from the same module Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * change domain data to fritz instance This let us use the fritz instance to reconnect from platform without accessing protected attributes. Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix typo Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * rename platform from fritzhome to fritzbox Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * Add device_state_attributes Add attributes to have compatiblity to fritzdect. Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add support for multiple fritzboxes Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix pylint issues Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fixed pyfritzhome version Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix import Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * fix component name in requirements_all.txt Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * upgrade pyfritzhome to 0.3.7 Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * rename platform/component also in .coveragerc Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * use DEFAULT_HOST when no host is in dict Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add config schema for dict Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove check The check since since the config scheme takes case. Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add check for empty devices Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * use standard attribute from base class Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove STATE_MANUAL from operation list Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove set DEFAULT_HOST Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * don't pass hass to the SwitchDevice Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove unsed DEFAULT_HOST Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * refactored device attributes Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * add info output if no fritzbox is configured Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * small fixes according review comment Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove unneeded default value Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove non required code from try..except block Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * line break for line that is too long Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> * remove too many empty lines Signed-off-by: Heiko Thiery <heiko.thiery@gmail.com> --- .coveragerc | 3 + homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/fritzbox.py | 153 +++++++++++++++++++ homeassistant/components/fritzbox.py | 83 ++++++++++ homeassistant/components/switch/fritzbox.py | 104 +++++++++++++ requirements_all.txt | 3 + 6 files changed, 347 insertions(+) create mode 100755 homeassistant/components/climate/fritzbox.py create mode 100755 homeassistant/components/fritzbox.py create mode 100755 homeassistant/components/switch/fritzbox.py diff --git a/.coveragerc b/.coveragerc index 70cfbded98f..3da28762df0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -94,6 +94,9 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/fritzbox.py + homeassistant/components/*/fritzbox.py + homeassistant/components/eufy.py homeassistant/components/*/eufy.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7ea23f4fd65..550d4035ddd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -40,6 +40,7 @@ STATE_HEAT = 'heat' STATE_COOL = 'cool' STATE_IDLE = 'idle' STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' STATE_ECO = 'eco' diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py new file mode 100755 index 00000000000..839da8c9d53 --- /dev/null +++ b/homeassistant/components/climate/fritzbox.py @@ -0,0 +1,153 @@ +""" +Support for AVM Fritz!Box smarthome thermostate devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/climate.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED) +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS) + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) + +OPERATION_LIST = [STATE_HEAT, STATE_ECO] + +MIN_TEMPERATURE = 8 +MAX_TEMPERATURE = 28 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome thermostat platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_thermostat: + devices.append(FritzboxThermostat(device, fritz)) + + add_devices(devices) + + +class FritzboxThermostat(ClimateDevice): + """The thermostat class for Fritzbox smarthome thermostates.""" + + def __init__(self, device, fritz): + """Initialize the thermostat.""" + self._device = device + self._fritz = fritz + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def available(self): + """Return if thermostat is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return precision 0.5.""" + return PRECISION_HALVES + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_OPERATION_MODE in kwargs: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + self.set_operation_mode(operation_mode) + elif ATTR_TEMPERATURE in kwargs: + temperature = kwargs.get(ATTR_TEMPERATURE) + self._device.set_target_temperature(temperature) + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._target_temperature == self._comfort_temperature: + return STATE_HEAT + elif self._target_temperature == self._eco_temperature: + return STATE_ECO + return STATE_MANUAL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + if operation_mode == STATE_HEAT: + self.set_temperature(temperature=self._comfort_temperature) + elif operation_mode == STATE_ECO: + self.set_temperature(temperature=self._eco_temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMPERATURE + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMPERATURE + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attrs = { + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + ATTR_STATE_BATTERY_LOW: self._device.battery_low, + } + return attrs + + def update(self): + """Update the data from the thermostat.""" + try: + self._device.update() + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzbox connection error: %s", ex) + self._fritz.login() diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py new file mode 100755 index 00000000000..a3c35aaa597 --- /dev/null +++ b/homeassistant/components/fritzbox.py @@ -0,0 +1,83 @@ +""" +Support for AVM Fritz!Box smarthome devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/fritzbox/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyfritzhome==0.3.7'] + +SUPPORTED_DOMAINS = ['climate', 'switch'] + +DOMAIN = 'fritzbox' + +ATTR_STATE_DEVICE_LOCKED = 'device_locked' +ATTR_STATE_LOCKED = 'locked' +ATTR_STATE_BATTERY_LOW = 'battery_low' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICES): + vol.All(cv.ensure_list, [ + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + }), + ]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the fritzbox component.""" + from pyfritzhome import Fritzhome, LoginError + + fritz_list = [] + + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + host = device.get(CONF_HOST) + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + fritzbox = Fritzhome(host=host, user=username, + password=password) + try: + fritzbox.login() + _LOGGER.info("Connected to device %s", device) + except LoginError: + _LOGGER.warning("Login to Fritz!Box %s as %s failed", + host, username) + continue + + fritz_list.append(fritzbox) + + if not fritz_list: + _LOGGER.info("No fritzboxes configured") + return False + + hass.data[DOMAIN] = fritz_list + + def logout_fritzboxes(event): + """Close all connections to the fritzboxes.""" + for fritz in fritz_list: + fritz.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes) + + for domain in SUPPORTED_DOMAINS: + discovery.load_platform(hass, domain, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py new file mode 100755 index 00000000000..c8313b0dfef --- /dev/null +++ b/homeassistant/components/switch/fritzbox.py @@ -0,0 +1,104 @@ +""" +Support for AVM Fritz!Box smarthome switch devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/switch.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' + +ATTR_TEMPERATURE_UNIT = 'temperature_unit' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome switch platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_switch: + devices.append(FritzboxSwitch(device, fritz)) + + add_devices(devices) + + +class FritzboxSwitch(SwitchDevice): + """The switch class for Fritzbox switches.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def available(self): + """Return if switch is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._device.switch_state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._device.set_switch_state_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._device.set_switch_state_off() + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock + attrs[ATTR_STATE_LOCKED] = self._device.lock + + if self._device.has_powermeter: + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + (self._device.energy or 0.0) / 100000) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = \ + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + if self._device.has_temperature_sensor: + attrs[ATTR_TEMPERATURE] = \ + str(self.hass.config.units.temperature( + self._device.temperature, TEMP_CELSIUS)) + attrs[ATTR_TEMPERATURE_UNIT] = \ + self.hass.config.units.temperature_unit + return attrs + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.power / 1000 diff --git a/requirements_all.txt b/requirements_all.txt index c2ee5814d7b..b3cf4dbeec1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,6 +765,9 @@ pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.fritzbox +pyfritzhome==0.3.7 + # homeassistant.components.ifttt pyfttt==0.3 -- GitLab