Skip to content
8 changes: 6 additions & 2 deletions homeassistant/components/homekit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -202,8 +205,9 @@ def start(self, *args):

# 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)
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/homekit/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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'
Expand All @@ -46,6 +47,7 @@
# CurrentPosition, TargetPosition, PositionState

# #### Characteristics ####
CHAR_ACTIVE = 'Active'
CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity'
CHAR_AIR_QUALITY = 'AirQuality'
CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100]
Expand Down Expand Up @@ -77,9 +79,11 @@
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]
Expand All @@ -88,6 +92,9 @@
CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits'

# #### Properties ####
PROP_MAX_VALUE = 'maxValue'
PROP_MIN_VALUE = 'minValue'

PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}

# #### Device Class ####
Expand Down
116 changes: 116 additions & 0 deletions homeassistant/components/homekit/type_fans.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently doesn't work if set from the frontend. See home-assistant/frontend#1158

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
6 changes: 4 additions & 2 deletions homeassistant/components/homekit/type_lights.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
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__)

Expand Down Expand Up @@ -61,7 +62,8 @@ def __init__(self, *args):
.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(
Expand Down
1 change: 1 addition & 0 deletions tests/components/homekit/test_get_accessories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {}, {}),

Expand Down
10 changes: 5 additions & 5 deletions tests/components/homekit/test_type_covers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@


@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):
Expand Down
149 changes: 149 additions & 0 deletions tests/components/homekit/test_type_fans.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions tests/components/homekit/test_type_lights.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@


@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):
Expand Down
Loading