Add HomeKit support for fans (#14351)
This commit is contained in:
parent
e20f88c143
commit
25dcddfeef
9 changed files with 294 additions and 15 deletions
|
@ -115,6 +115,9 @@ def get_accessory(hass, state, aid, config):
|
|||
elif features & (SUPPORT_OPEN | SUPPORT_CLOSE):
|
||||
a_type = 'WindowCoveringBasic'
|
||||
|
||||
elif state.domain == 'fan':
|
||||
a_type = 'Fan'
|
||||
|
||||
elif state.domain == 'light':
|
||||
a_type = 'Light'
|
||||
|
||||
|
@ -202,8 +205,9 @@ class HomeKit():
|
|||
|
||||
# pylint: disable=unused-variable
|
||||
from . import ( # noqa F401
|
||||
type_covers, type_lights, type_locks, type_security_systems,
|
||||
type_sensors, type_switches, type_thermostats)
|
||||
type_covers, type_fans, type_lights, type_locks,
|
||||
type_security_systems, type_sensors, type_switches,
|
||||
type_thermostats)
|
||||
|
||||
for state in self.hass.states.all():
|
||||
self.add_bridge_accessory(state)
|
||||
|
|
|
@ -29,6 +29,7 @@ SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor'
|
|||
SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor'
|
||||
SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor'
|
||||
SERV_CONTACT_SENSOR = 'ContactSensor'
|
||||
SERV_FANV2 = 'Fanv2'
|
||||
SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener'
|
||||
SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity
|
||||
SERV_LEAK_SENSOR = 'LeakSensor'
|
||||
|
@ -46,6 +47,7 @@ SERV_WINDOW_COVERING = 'WindowCovering'
|
|||
# CurrentPosition, TargetPosition, PositionState
|
||||
|
||||
# #### Characteristics ####
|
||||
CHAR_ACTIVE = 'Active'
|
||||
CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity'
|
||||
CHAR_AIR_QUALITY = 'AirQuality'
|
||||
CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100]
|
||||
|
@ -77,9 +79,11 @@ CHAR_NAME = 'Name'
|
|||
CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected'
|
||||
CHAR_ON = 'On' # boolean
|
||||
CHAR_POSITION_STATE = 'PositionState'
|
||||
CHAR_ROTATION_DIRECTION = 'RotationDirection'
|
||||
CHAR_SATURATION = 'Saturation' # percent
|
||||
CHAR_SERIAL_NUMBER = 'SerialNumber'
|
||||
CHAR_SMOKE_DETECTED = 'SmokeDetected'
|
||||
CHAR_SWING_MODE = 'SwingMode'
|
||||
CHAR_TARGET_DOOR_STATE = 'TargetDoorState'
|
||||
CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState'
|
||||
CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100]
|
||||
|
@ -88,6 +92,9 @@ CHAR_TARGET_TEMPERATURE = 'TargetTemperature'
|
|||
CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits'
|
||||
|
||||
# #### Properties ####
|
||||
PROP_MAX_VALUE = 'maxValue'
|
||||
PROP_MIN_VALUE = 'minValue'
|
||||
|
||||
PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}
|
||||
|
||||
# #### Device Class ####
|
||||
|
|
116
homeassistant/components/homekit/type_fans.py
Normal file
116
homeassistant/components/homekit/type_fans.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
"""Class to hold all light accessories."""
|
||||
import logging
|
||||
|
||||
from pyhap.const import CATEGORY_FAN
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION, ATTR_OSCILLATING,
|
||||
DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
|
||||
SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, SUPPORT_OSCILLATE)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON,
|
||||
SERVICE_TURN_OFF, SERVICE_TURN_ON)
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory
|
||||
from .const import (
|
||||
CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@TYPES.register('Fan')
|
||||
class Fan(HomeAccessory):
|
||||
"""Generate a Fan accessory for a fan entity.
|
||||
|
||||
Currently supports: state, speed, oscillate, direction.
|
||||
"""
|
||||
|
||||
def __init__(self, *args):
|
||||
"""Initialize a new Light accessory object."""
|
||||
super().__init__(*args, category=CATEGORY_FAN)
|
||||
self._flag = {CHAR_ACTIVE: False,
|
||||
CHAR_ROTATION_DIRECTION: False,
|
||||
CHAR_SWING_MODE: False}
|
||||
self._state = 0
|
||||
|
||||
self.chars = []
|
||||
features = self.hass.states.get(self.entity_id) \
|
||||
.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
if features & SUPPORT_DIRECTION:
|
||||
self.chars.append(CHAR_ROTATION_DIRECTION)
|
||||
if features & SUPPORT_OSCILLATE:
|
||||
self.chars.append(CHAR_SWING_MODE)
|
||||
|
||||
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
|
||||
self.char_active = serv_fan.configure_char(
|
||||
CHAR_ACTIVE, value=0, setter_callback=self.set_state)
|
||||
|
||||
if CHAR_ROTATION_DIRECTION in self.chars:
|
||||
self.char_direction = serv_fan.configure_char(
|
||||
CHAR_ROTATION_DIRECTION, value=0,
|
||||
setter_callback=self.set_direction)
|
||||
|
||||
if CHAR_SWING_MODE in self.chars:
|
||||
self.char_swing = serv_fan.configure_char(
|
||||
CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating)
|
||||
|
||||
def set_state(self, value):
|
||||
"""Set state if call came from HomeKit."""
|
||||
if self._state == value:
|
||||
return
|
||||
|
||||
_LOGGER.debug('%s: Set state to %d', self.entity_id, value)
|
||||
self._flag[CHAR_ACTIVE] = True
|
||||
service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
|
||||
params = {ATTR_ENTITY_ID: self.entity_id}
|
||||
self.hass.services.call(DOMAIN, service, params)
|
||||
|
||||
def set_direction(self, value):
|
||||
"""Set state if call came from HomeKit."""
|
||||
_LOGGER.debug('%s: Set direction to %d', self.entity_id, value)
|
||||
self._flag[CHAR_ROTATION_DIRECTION] = True
|
||||
direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD
|
||||
params = {ATTR_ENTITY_ID: self.entity_id,
|
||||
ATTR_DIRECTION: direction}
|
||||
self.hass.services.call(DOMAIN, SERVICE_SET_DIRECTION, params)
|
||||
|
||||
def set_oscillating(self, value):
|
||||
"""Set state if call came from HomeKit."""
|
||||
_LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value)
|
||||
self._flag[CHAR_SWING_MODE] = True
|
||||
oscillating = True if value == 1 else False
|
||||
params = {ATTR_ENTITY_ID: self.entity_id,
|
||||
ATTR_OSCILLATING: oscillating}
|
||||
self.hass.services.call(DOMAIN, SERVICE_OSCILLATE, params)
|
||||
|
||||
def update_state(self, new_state):
|
||||
"""Update fan after state change."""
|
||||
# Handle State
|
||||
state = new_state.state
|
||||
if state in (STATE_ON, STATE_OFF):
|
||||
self._state = 1 if state == STATE_ON else 0
|
||||
if not self._flag[CHAR_ACTIVE] and \
|
||||
self.char_active.value != self._state:
|
||||
self.char_active.set_value(self._state)
|
||||
self._flag[CHAR_ACTIVE] = False
|
||||
|
||||
# Handle Direction
|
||||
if CHAR_ROTATION_DIRECTION in self.chars:
|
||||
direction = new_state.attributes.get(ATTR_DIRECTION)
|
||||
if not self._flag[CHAR_ROTATION_DIRECTION] and \
|
||||
direction in (DIRECTION_FORWARD, DIRECTION_REVERSE):
|
||||
hk_direction = 1 if direction == DIRECTION_REVERSE else 0
|
||||
if self.char_direction.value != hk_direction:
|
||||
self.char_direction.set_value(hk_direction)
|
||||
self._flag[CHAR_ROTATION_DIRECTION] = False
|
||||
|
||||
# Handle Oscillating
|
||||
if CHAR_SWING_MODE in self.chars:
|
||||
oscillating = new_state.attributes.get(ATTR_OSCILLATING)
|
||||
if not self._flag[CHAR_SWING_MODE] and \
|
||||
oscillating in (True, False):
|
||||
hk_oscillating = 1 if oscillating else 0
|
||||
if self.char_swing.value != hk_oscillating:
|
||||
self.char_swing.set_value(hk_oscillating)
|
||||
self._flag[CHAR_SWING_MODE] = False
|
|
@ -12,7 +12,8 @@ from . import TYPES
|
|||
from .accessories import HomeAccessory, debounce
|
||||
from .const import (
|
||||
SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE,
|
||||
CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION)
|
||||
CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION,
|
||||
PROP_MAX_VALUE, PROP_MIN_VALUE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -61,7 +62,8 @@ class Light(HomeAccessory):
|
|||
.attributes.get(ATTR_MAX_MIREDS, 500)
|
||||
self.char_color_temperature = serv_light.configure_char(
|
||||
CHAR_COLOR_TEMPERATURE, value=min_mireds,
|
||||
properties={'minValue': min_mireds, 'maxValue': max_mireds},
|
||||
properties={PROP_MIN_VALUE: min_mireds,
|
||||
PROP_MAX_VALUE: max_mireds},
|
||||
setter_callback=self.set_color_temperature)
|
||||
if CHAR_HUE in self.chars:
|
||||
self.char_hue = serv_light.configure_char(
|
||||
|
|
|
@ -37,6 +37,7 @@ def test_customize_options(config, name):
|
|||
|
||||
|
||||
@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
|
||||
('Fan', 'fan.test', 'on', {}, {}),
|
||||
('Light', 'light.test', 'on', {}, {}),
|
||||
('Lock', 'lock.test', 'locked', {}, {}),
|
||||
|
||||
|
|
|
@ -14,18 +14,18 @@ from tests.components.homekit.test_accessories import patch_debounce
|
|||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def cls(request):
|
||||
def cls():
|
||||
"""Patch debounce decorator during import of type_covers."""
|
||||
patcher = patch_debounce()
|
||||
patcher.start()
|
||||
_import = __import__('homeassistant.components.homekit.type_covers',
|
||||
fromlist=['GarageDoorOpener', 'WindowCovering,',
|
||||
'WindowCoveringBasic'])
|
||||
request.addfinalizer(patcher.stop)
|
||||
patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage'])
|
||||
return patcher_tuple(window=_import.WindowCovering,
|
||||
window_basic=_import.WindowCoveringBasic,
|
||||
garage=_import.GarageDoorOpener)
|
||||
yield patcher_tuple(window=_import.WindowCovering,
|
||||
window_basic=_import.WindowCoveringBasic,
|
||||
garage=_import.GarageDoorOpener)
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_garage_door_open_close(hass, cls):
|
||||
|
|
149
tests/components/homekit/test_type_fans.py
Normal file
149
tests/components/homekit/test_type_fans.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
"""Test different accessory types: Fans."""
|
||||
from collections import namedtuple
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION, ATTR_OSCILLATING,
|
||||
DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
|
||||
SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, SUPPORT_OSCILLATE)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
|
||||
STATE_ON, STATE_OFF, STATE_UNKNOWN, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
|
||||
from tests.common import async_mock_service
|
||||
from tests.components.homekit.test_accessories import patch_debounce
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def cls():
|
||||
"""Patch debounce decorator during import of type_fans."""
|
||||
patcher = patch_debounce()
|
||||
patcher.start()
|
||||
_import = __import__('homeassistant.components.homekit.type_fans',
|
||||
fromlist=['Fan'])
|
||||
patcher_tuple = namedtuple('Cls', ['fan'])
|
||||
yield patcher_tuple(fan=_import.Fan)
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_fan_basic(hass, cls):
|
||||
"""Test fan with char state."""
|
||||
entity_id = 'fan.demo'
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON,
|
||||
{ATTR_SUPPORTED_FEATURES: 0})
|
||||
await hass.async_block_till_done()
|
||||
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
|
||||
|
||||
assert acc.aid == 2
|
||||
assert acc.category == 3 # Fan
|
||||
assert acc.char_active.value == 0
|
||||
|
||||
await hass.async_add_job(acc.run)
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_active.value == 1
|
||||
|
||||
hass.states.async_set(entity_id, STATE_OFF,
|
||||
{ATTR_SUPPORTED_FEATURES: 0})
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_active.value == 0
|
||||
|
||||
hass.states.async_set(entity_id, STATE_UNKNOWN)
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_active.value == 0
|
||||
|
||||
hass.states.async_remove(entity_id)
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_active.value == 0
|
||||
|
||||
# Set from HomeKit
|
||||
call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
|
||||
call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
|
||||
|
||||
await hass.async_add_job(acc.char_active.client_update_value, 1)
|
||||
await hass.async_block_till_done()
|
||||
assert call_turn_on
|
||||
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.async_add_job(acc.char_active.client_update_value, 0)
|
||||
await hass.async_block_till_done()
|
||||
assert call_turn_off
|
||||
assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
async def test_fan_direction(hass, cls):
|
||||
"""Test fan with direction."""
|
||||
entity_id = 'fan.demo'
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON, {
|
||||
ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION,
|
||||
ATTR_DIRECTION: DIRECTION_FORWARD})
|
||||
await hass.async_block_till_done()
|
||||
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
|
||||
|
||||
assert acc.char_direction.value == 0
|
||||
|
||||
await hass.async_add_job(acc.run)
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_direction.value == 0
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON,
|
||||
{ATTR_DIRECTION: DIRECTION_REVERSE})
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_direction.value == 1
|
||||
|
||||
# Set from HomeKit
|
||||
call_set_direction = async_mock_service(hass, DOMAIN,
|
||||
SERVICE_SET_DIRECTION)
|
||||
|
||||
await hass.async_add_job(acc.char_direction.client_update_value, 0)
|
||||
await hass.async_block_till_done()
|
||||
assert call_set_direction[0]
|
||||
assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id
|
||||
assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD
|
||||
|
||||
await hass.async_add_job(acc.char_direction.client_update_value, 1)
|
||||
await hass.async_block_till_done()
|
||||
assert call_set_direction[1]
|
||||
assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id
|
||||
assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE
|
||||
|
||||
|
||||
async def test_fan_oscillate(hass, cls):
|
||||
"""Test fan with oscillate."""
|
||||
entity_id = 'fan.demo'
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON, {
|
||||
ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False})
|
||||
await hass.async_block_till_done()
|
||||
acc = cls.fan(hass, 'Fan', entity_id, 2, None)
|
||||
|
||||
assert acc.char_swing.value == 0
|
||||
|
||||
await hass.async_add_job(acc.run)
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_swing.value == 0
|
||||
|
||||
hass.states.async_set(entity_id, STATE_ON,
|
||||
{ATTR_OSCILLATING: True})
|
||||
await hass.async_block_till_done()
|
||||
assert acc.char_swing.value == 1
|
||||
|
||||
# Set from HomeKit
|
||||
call_oscillate = async_mock_service(hass, DOMAIN, SERVICE_OSCILLATE)
|
||||
|
||||
await hass.async_add_job(acc.char_swing.client_update_value, 0)
|
||||
await hass.async_block_till_done()
|
||||
assert call_oscillate[0]
|
||||
assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id
|
||||
assert call_oscillate[0].data[ATTR_OSCILLATING] is False
|
||||
|
||||
await hass.async_add_job(acc.char_swing.client_update_value, 1)
|
||||
await hass.async_block_till_done()
|
||||
assert call_oscillate[1]
|
||||
assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id
|
||||
assert call_oscillate[1].data[ATTR_OSCILLATING] is True
|
|
@ -15,15 +15,15 @@ from tests.components.homekit.test_accessories import patch_debounce
|
|||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def cls(request):
|
||||
def cls():
|
||||
"""Patch debounce decorator during import of type_lights."""
|
||||
patcher = patch_debounce()
|
||||
patcher.start()
|
||||
_import = __import__('homeassistant.components.homekit.type_lights',
|
||||
fromlist=['Light'])
|
||||
request.addfinalizer(patcher.stop)
|
||||
patcher_tuple = namedtuple('Cls', ['light'])
|
||||
return patcher_tuple(light=_import.Light)
|
||||
yield patcher_tuple(light=_import.Light)
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_light_basic(hass, cls):
|
||||
|
|
|
@ -16,15 +16,15 @@ from tests.components.homekit.test_accessories import patch_debounce
|
|||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def cls(request):
|
||||
def cls():
|
||||
"""Patch debounce decorator during import of type_thermostats."""
|
||||
patcher = patch_debounce()
|
||||
patcher.start()
|
||||
_import = __import__('homeassistant.components.homekit.type_thermostats',
|
||||
fromlist=['Thermostat'])
|
||||
request.addfinalizer(patcher.stop)
|
||||
patcher_tuple = namedtuple('Cls', ['thermostat'])
|
||||
return patcher_tuple(thermostat=_import.Thermostat)
|
||||
yield patcher_tuple(thermostat=_import.Thermostat)
|
||||
patcher.stop()
|
||||
|
||||
|
||||
async def test_default_thermostat(hass, cls):
|
||||
|
|
Loading…
Add table
Reference in a new issue