diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py new file mode 100644 index 00000000000..d4af7562920 --- /dev/null +++ b/homeassistant/components/fan/__init__.py @@ -0,0 +1,224 @@ +""" +Provides functionality to interact with fans. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan/ +""" +import logging +import os + +import voluptuous as vol + +from homeassistant.components import group +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + STATE_OFF, SERVICE_TURN_ON, + SERVICE_TURN_OFF, ATTR_ENTITY_ID) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv + + +DOMAIN = 'fan' +SCAN_INTERVAL = 30 + +GROUP_NAME_ALL_FANS = 'all fans' +ENTITY_ID_ALL_FANS = group.ENTITY_ID_FORMAT.format(GROUP_NAME_ALL_FANS) + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +# Bitfield of features supported by the fan entity +ATTR_SUPPORTED_FEATURES = 'supported_features' +SUPPORT_SET_SPEED = 1 +SUPPORT_OSCILLATE = 2 + +SERVICE_SET_SPEED = 'set_speed' +SERVICE_OSCILLATE = 'oscillate' + +SPEED_OFF = 'off' +SPEED_LOW = 'low' +SPEED_MED = 'med' +SPEED_HIGH = 'high' + +ATTR_SPEED = 'speed' +ATTR_SPEED_LIST = 'speed_list' +ATTR_OSCILLATE = 'oscillate' + +PROP_TO_ATTR = { + 'speed': ATTR_SPEED, + 'speed_list': ATTR_SPEED_LIST, + 'oscillate': ATTR_OSCILLATE, + 'supported_features': ATTR_SUPPORTED_FEATURES, +} # type: dict + +FAN_SET_SPEED_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_SPEED): cv.string +}) # type: dict + +FAN_TURN_ON_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_SPEED): cv.string +}) # type: dict + +FAN_TURN_OFF_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids +}) # type: dict + +FAN_OSCILLATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OSCILLATE): cv.boolean +}) # type: dict + +_LOGGER = logging.getLogger(__name__) + + +def is_on(hass, entity_id: str=None) -> bool: + """Return if the fans are on based on the statemachine.""" + entity_id = entity_id or ENTITY_ID_ALL_FANS + return not hass.states.is_state(entity_id, STATE_OFF) + + +# pylint: disable=too-many-arguments +def turn_on(hass, entity_id: str=None, speed: str=None) -> None: + """Turn all or specified fan on.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id: str=None) -> None: + """Turn all or specified fan off.""" + data = { + ATTR_ENTITY_ID: entity_id, + } + + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + + +def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: + """Set oscillation on all or specified fan.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_OSCILLATE, should_oscillate), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_OSCILLATE, data) + + +def set_speed(hass, entity_id: str=None, speed: str=None) -> None: + """Set speed for all or specified fan.""" + data = { + key: value for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + ] if value is not None + } + + hass.services.call(DOMAIN, SERVICE_SET_SPEED, data) + + +# pylint: disable=too-many-branches, too-many-locals, too-many-statements +def setup(hass, config: dict) -> None: + """Expose fan control via statemachine and services.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_FANS) + component.setup(config) + + def handle_fan_service(service: str) -> None: + """Hande service call for fans.""" + # Get the validated data + params = service.data.copy() + + # Convert the entity ids to valid fan ids + target_fans = component.extract_from_service(service) + params.pop(ATTR_ENTITY_ID, None) + + service_fun = None + for service_def in [SERVICE_TURN_ON, SERVICE_TURN_OFF, + SERVICE_SET_SPEED, SERVICE_OSCILLATE]: + if service_def == service.service: + service_fun = service_def + break + + if service_fun: + for fan in target_fans: + getattr(fan, service_fun)(**params) + + for fan in target_fans: + if fan.should_poll: + fan.update_ha_state(True) + return + + # Listen for fan service calls. + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_fan_service, + descriptions.get(SERVICE_TURN_ON), + schema=FAN_TURN_ON_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_fan_service, + descriptions.get(SERVICE_TURN_OFF), + schema=FAN_TURN_OFF_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_SET_SPEED, handle_fan_service, + descriptions.get(SERVICE_SET_SPEED), + schema=FAN_SET_SPEED_SCHEMA) + + hass.services.register(DOMAIN, SERVICE_OSCILLATE, handle_fan_service, + descriptions.get(SERVICE_OSCILLATE), + schema=FAN_OSCILLATE_SCHEMA) + + return True + + +class FanEntity(Entity): + """Representation of a fan.""" + + # pylint: disable=no-self-use, abstract-method + + def set_speed(self: Entity, speed: str) -> None: + """Set the speed of the fan.""" + pass + + def turn_on(self: Entity, speed: str=None) -> None: + """Turn on the fan.""" + pass + + def turn_off(self: Entity) -> None: + """Turn off the fan.""" + pass + + def oscillate(self: Entity) -> None: + """Oscillate the fan.""" + pass + + @property + def speed_list(self: Entity) -> list: + """Get the list of available speeds.""" + return [] + + @property + def state_attributes(self: Entity) -> dict: + """Return optional state attributes.""" + data = {} # type: dict + + for prop, attr in PROP_TO_ATTR.items(): + value = getattr(self, prop) + if value is not None: + data[attr] = value + + return data + + @property + def supported_features(self: Entity) -> int: + """Flag supported features.""" + return 0 diff --git a/homeassistant/components/fan/insteon_hub.py b/homeassistant/components/fan/insteon_hub.py new file mode 100644 index 00000000000..2f24bb0bc9b --- /dev/null +++ b/homeassistant/components/fan/insteon_hub.py @@ -0,0 +1,71 @@ +""" +Support for Insteon FanLinc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.insteon/ +""" + +import logging + +from homeassistant.components.fan import (FanEntity, SUPPORT_SET_SPEED, + SPEED_OFF, SPEED_LOW, SPEED_MED, + SPEED_HIGH) +from homeassistant.components.insteon_hub import (InsteonDevice, INSTEON, + filter_devices) +from homeassistant.const import STATE_UNKNOWN + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CATEGORIES = [ + { + 'DevCat': 1, + 'SubCat': [46] + } +] + +DEPENDENCIES = ['insteon_hub'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Insteon Hub fan platform.""" + devs = [] + for device in filter_devices(INSTEON.devices, DEVICE_CATEGORIES): + devs.append(InsteonFanDevice(device)) + add_devices(devs) + + +class InsteonFanDevice(InsteonDevice, FanEntity): + """Represet an insteon fan device.""" + + def __init__(self, node: object) -> None: + """Initialize the device.""" + super(InsteonFanDevice, self).__init__(node) + self.speed = STATE_UNKNOWN # Insteon hub can't get state via REST + + def turn_on(self, speed: str=None): + """Turn the fan on.""" + self.set_speed(speed if speed else SPEED_MED) + + def turn_off(self): + """Turn the fan off.""" + self.set_speed(SPEED_OFF) + + def set_speed(self, speed: str) -> None: + """Set the fan speed.""" + if self._send_command('fan', payload={'speed', speed}): + self.speed = speed + + @property + def supported_features(self) -> int: + """Get the supported features for device.""" + return SUPPORT_SET_SPEED + + @property + def speed_list(self) -> list: + """Get the available speeds for the fan.""" + return [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH] + + @property + def state(self) -> str: + """Get the current device state.""" + return self.speed diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml new file mode 100644 index 00000000000..4f20e6036ce --- /dev/null +++ b/homeassistant/components/fan/services.yaml @@ -0,0 +1,45 @@ +# Describes the format for available fan services + +set_speed: + description: Sets fan speed + + fields: + entity_id: + description: Name(s) of the entities to set + example: 'fan.living_room' + + speed: + description: Speed setting + example: 'low' + +turn_on: + description: Turns fan on + + fields: + entity_id: + description: Names(s) of the entities to turn on + example: 'fan.living_room' + + speed: + description: Speed setting + example: 'high' + +turn_off: + description: Turns fan off + + fields: + entity_id: + description: Names(s) of the entities to turn off + example: 'fan.living_room' + +oscillate: + description: Oscillates the fan + + fields: + entity_id: + description: Name(s) of the entities to oscillate + example: 'fan.desk_fan' + + oscillate: + description: Flag to turn on/off oscillation + example: True \ No newline at end of file diff --git a/homeassistant/components/insteon_hub.py b/homeassistant/components/insteon_hub.py index 306acab5361..3ad107886b8 100644 --- a/homeassistant/components/insteon_hub.py +++ b/homeassistant/components/insteon_hub.py @@ -8,37 +8,93 @@ import logging from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import validate_config, discovery +from homeassistant.helpers.entity import Entity + +DOMAIN = 'insteon_hub' # type: str +REQUIREMENTS = ['insteon_hub==0.5.0'] # type: list +INSTEON = None # type: Insteon +DEVCAT = 'DevCat' # type: str +SUBCAT = 'SubCat' # type: str +DEVICE_CLASSES = ['light', 'fan'] # type: list -DOMAIN = "insteon_hub" -REQUIREMENTS = ['insteon_hub==0.4.5'] -INSTEON = None _LOGGER = logging.getLogger(__name__) -def setup(hass, config): - """Setup Insteon Hub component. +def _is_successful(response: dict) -> bool: + """Check http response for successful status.""" + return 'status' in response and response['status'] == 'succeeded' - This will automatically import associated lights. - """ + +def filter_devices(devices: list, categories: list) -> list: + """Filter insteon device list by category/subcategory.""" + categories = (categories + if isinstance(categories, list) + else [categories]) + matching_devices = [] + for device in devices: + if any( + device.DevCat == c[DEVCAT] and + (SUBCAT not in c or device.SubCat in c[SUBCAT]) + for c in categories): + matching_devices.append(device) + return matching_devices + + +def setup(hass, config: dict) -> bool: + """Setup Insteon Hub component.""" if not validate_config( config, {DOMAIN: [CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY]}, _LOGGER): return False - import insteon + from insteon import Insteon username = config[DOMAIN][CONF_USERNAME] password = config[DOMAIN][CONF_PASSWORD] api_key = config[DOMAIN][CONF_API_KEY] global INSTEON - INSTEON = insteon.Insteon(username, password, api_key) + INSTEON = Insteon(username, password, api_key) if INSTEON is None: - _LOGGER.error("Could not connect to Insteon service.") + _LOGGER.error('Could not connect to Insteon service.') return - discovery.load_platform(hass, 'light', DOMAIN, {}, config) - + for device_class in DEVICE_CLASSES: + discovery.load_platform(hass, device_class, DOMAIN, {}, config) return True + + +class InsteonDevice(Entity): + """Represents an insteon device.""" + + def __init__(self: Entity, node: object) -> None: + """Initialize the insteon device.""" + self._node = node + + def update(self: Entity) -> None: + """Update state of the device.""" + pass + + @property + def name(self: Entity) -> str: + """Name of the insteon device.""" + return self._node.DeviceName + + @property + def unique_id(self: Entity) -> str: + """Unique identifier for the device.""" + return self._node.DeviceID + + @property + def supported_features(self: Entity) -> int: + """Supported feature flags.""" + return 0 + + def _send_command(self: Entity, command: str, level: int=None, + payload: dict=None) -> bool: + """Send command to insteon device.""" + resp = self._node.send_command(command, payload=payload, level=level, + wait=True) + return _is_successful(resp) diff --git a/homeassistant/components/light/insteon_hub.py b/homeassistant/components/light/insteon_hub.py index 70beadb6c1d..29254735ced 100644 --- a/homeassistant/components/light/insteon_hub.py +++ b/homeassistant/components/light/insteon_hub.py @@ -4,74 +4,76 @@ Support for Insteon Hub lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/insteon_hub/ """ -from homeassistant.components.insteon_hub import INSTEON +from homeassistant.components.insteon_hub import (INSTEON, InsteonDevice) from homeassistant.components.light import (ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) SUPPORT_INSTEON_HUB = SUPPORT_BRIGHTNESS +DEPENDENCIES = ['insteon_hub'] + def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Insteon Hub light platform.""" devs = [] for device in INSTEON.devices: if device.DeviceCategory == "Switched Lighting Control": - devs.append(InsteonToggleDevice(device)) + devs.append(InsteonLightDevice(device)) if device.DeviceCategory == "Dimmable Lighting Control": - devs.append(InsteonToggleDevice(device)) + devs.append(InsteonDimmableDevice(device)) add_devices(devs) -class InsteonToggleDevice(Light): - """An abstract Class for an Insteon node.""" +class InsteonLightDevice(InsteonDevice, Light): + """A representation of a light device.""" - def __init__(self, node): + def __init__(self, node: object) -> None: """Initialize the device.""" - self.node = node + super(InsteonLightDevice, self).__init__(node) self._value = 0 - @property - def name(self): - """Return the the name of the node.""" - return self.node.DeviceName - - @property - def unique_id(self): - """Return the ID of this insteon node.""" - return self.node.DeviceID - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._value / 100 * 255 - - def update(self): - """Update state of the sensor.""" - resp = self.node.send_command('get_status', wait=True) + def update(self) -> None: + """Update state of the device.""" + resp = self._node.send_command('get_status', wait=True) try: self._value = resp['response']['level'] except KeyError: pass @property - def is_on(self): + def is_on(self) -> None: """Return the boolean response if the node is on.""" return self._value != 0 + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + if self._send_command('on'): + self._value = 100 + + def turn_off(self, **kwargs) -> None: + """Turn device off.""" + if self._send_command('off'): + self._value = 0 + + +class InsteonDimmableDevice(InsteonLightDevice): + """A representation for a dimmable device.""" + @property - def supported_features(self): + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return round(self._value / 100 * 255, 0) # type: int + + @property + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_INSTEON_HUB - def turn_on(self, **kwargs): + def turn_on(self, **kwargs) -> None: """Turn device on.""" + level = 100 # type: int if ATTR_BRIGHTNESS in kwargs: - self._value = kwargs[ATTR_BRIGHTNESS] / 255 * 100 - self.node.send_command('on', self._value) - else: - self._value = 100 - self.node.send_command('on') + level = round(kwargs[ATTR_BRIGHTNESS] / 255 * 100, 0) # type: int - def turn_off(self, **kwargs): - """Turn device off.""" - self.node.send_command('off') + if self._send_command('on', level=level): + self._value = level diff --git a/requirements_all.txt b/requirements_all.txt index e9d125d764a..2bb39e5e69a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 influxdb==3.0.0 # homeassistant.components.insteon_hub -insteon_hub==0.4.5 +insteon_hub==0.5.0 # homeassistant.components.media_player.kodi jsonrpc-requests==0.3 diff --git a/tests/components/fan/__init__.py b/tests/components/fan/__init__.py new file mode 100644 index 00000000000..251a118e32e --- /dev/null +++ b/tests/components/fan/__init__.py @@ -0,0 +1 @@ +"""Test fan component plaforms.""" diff --git a/tests/components/fan/test_insteon_hub.py b/tests/components/fan/test_insteon_hub.py new file mode 100644 index 00000000000..270687c9fd3 --- /dev/null +++ b/tests/components/fan/test_insteon_hub.py @@ -0,0 +1,76 @@ +"""Tests for the insteon hub fan platform.""" +import unittest + +from homeassistant.const import STATE_OFF, STATE_UNKNOWN +from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH) +from homeassistant.components.fan.insteon_hub import (InsteonFanDevice, + SUPPORT_SET_SPEED) + + +class Node(object): + """Fake insteon node.""" + + def __init__(self, name, id, dev_cat, sub_cat): + """Initialize fake insteon node.""" + self.DeviceName = name + self.DeviceID = id + self.DevCat = dev_cat + self.SubCat = sub_cat + self.response = None + + def send_command(self, command, payload, level, wait): + """Send fake command.""" + return self.response + + +class TestInsteonHubFanDevice(unittest.TestCase): + """Test around insteon hub fan device methods.""" + + _NODE = Node('device', '12345', '1', '46') + + def setUp(self): + """Initialize test data.""" + self._DEVICE = InsteonFanDevice(self._NODE) + + def tearDown(self): + """Tear down test data.""" + self._DEVICE = None + + def test_properties(self): + """Test basic properties.""" + self.assertEqual(self._NODE.DeviceName, self._DEVICE.name) + self.assertEqual(self._NODE.DeviceID, self._DEVICE.unique_id) + self.assertEqual(SUPPORT_SET_SPEED, self._DEVICE.supported_features) + + for speed in [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH]: + self.assertIn(speed, self._DEVICE.speed_list) + + def test_turn_on(self): + """Test the turning on device.""" + self._NODE.response = { + 'status': 'succeeded' + } + self.assertEqual(STATE_UNKNOWN, self._DEVICE.state) + self._DEVICE.turn_on() + + self.assertNotEqual(STATE_OFF, self._DEVICE.state) + + self._DEVICE.turn_on(SPEED_MED) + + self.assertEqual(SPEED_MED, self._DEVICE.state) + + self._NODE.response = { + } + self._DEVICE.turn_on(SPEED_HIGH) + + self.assertNotEqual(SPEED_HIGH, self._DEVICE.state) + + def test_turn_off(self): + """Test turning off device.""" + self._NODE.response = { + 'status': 'succeeded' + } + self.assertEqual(STATE_UNKNOWN, self._DEVICE.state) + self._DEVICE.turn_off() + + self.assertEqual(STATE_OFF, self._DEVICE.state)