Add Kira component to sensor and remote platforms (#7479)

* Add Kira component to sensor and remote platforms

* Test cases for Kira component and platforms
This commit is contained in:
Stu Gott 2017-05-13 00:12:47 -04:00 committed by Paulus Schoutsen
parent 4cdf0b4969
commit 9c4bc2a47f
10 changed files with 509 additions and 2 deletions

View file

@ -62,6 +62,9 @@ omit =
homeassistant/components/isy994.py
homeassistant/components/*/isy994.py
homeassistant/components/kira.py
homeassistant/components/*/kira.py
homeassistant/components/lutron.py
homeassistant/components/*/lutron.py

View file

@ -0,0 +1,142 @@
"""KIRA interface to receive UDP packets from an IR-IP bridge."""
# pylint: disable=import-error
import logging
import os
import yaml
import voluptuous as vol
from voluptuous.error import Error as VoluptuousError
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
CONF_DEVICE,
CONF_HOST,
CONF_NAME,
CONF_PORT,
CONF_SENSORS,
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
STATE_UNKNOWN)
REQUIREMENTS = ["pykira==0.1.1"]
DOMAIN = 'kira'
_LOGGER = logging.getLogger(__name__)
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 65432
CONF_CODE = "code"
CONF_REPEAT = "repeat"
CONF_REMOTES = "remotes"
CONF_SENSOR = "sensor"
CONF_REMOTE = "remote"
CODES_YAML = '{}_codes.yaml'.format(DOMAIN)
CODE_SCHEMA = vol.Schema({
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_CODE): cv.string,
vol.Optional(CONF_TYPE): cv.string,
vol.Optional(CONF_DEVICE): cv.string,
vol.Optional(CONF_REPEAT): cv.positive_int,
})
SENSOR_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME, default=DOMAIN):
vol.Exclusive(cv.string, "sensors"),
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
})
REMOTE_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME, default=DOMAIN):
vol.Exclusive(cv.string, "remotes"),
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_SENSORS): [SENSOR_SCHEMA],
vol.Optional(CONF_REMOTES): [REMOTE_SCHEMA]})
}, extra=vol.ALLOW_EXTRA)
def load_codes(path):
"""Load Kira codes from specified file."""
codes = []
if os.path.exists(path):
with open(path) as code_file:
data = yaml.load(code_file) or []
for code in data:
try:
codes.append(CODE_SCHEMA(code))
except VoluptuousError as exception:
# keep going
_LOGGER.warning('Kira Code Invalid Data: %s', exception)
else:
with open(path, 'w') as code_file:
code_file.write('')
return codes
def setup(hass, config):
"""Setup KIRA capability."""
import pykira
sensors = config.get(DOMAIN, {}).get(CONF_SENSORS, [])
remotes = config.get(DOMAIN, {}).get(CONF_REMOTES, [])
# If no sensors or remotes were specified, add a sensor
if not(sensors or remotes):
sensors.append({})
codes = load_codes(hass.config.path(CODES_YAML))
hass.data[DOMAIN] = {
CONF_SENSOR: {},
CONF_REMOTE: {},
}
def load_module(platform, idx, module_conf):
"""Set up Kira module and load platform."""
# note: module_name is not the HA device name. it's just a unique name
# to ensure the component and platform can share information
module_name = ("%s_%d" % (DOMAIN, idx)) if idx else DOMAIN
device_name = module_conf.get(CONF_NAME, DOMAIN)
port = module_conf.get(CONF_PORT, DEFAULT_PORT)
host = module_conf.get(CONF_HOST, DEFAULT_HOST)
if platform == CONF_SENSOR:
module = pykira.KiraReceiver(host, port)
module.start()
else:
module = pykira.KiraModule(host, port)
hass.data[DOMAIN][platform][module_name] = module
for code in codes:
code_tuple = (code.get(CONF_NAME),
code.get(CONF_DEVICE, STATE_UNKNOWN))
module.registerCode(code_tuple, code.get(CONF_CODE))
discovery.load_platform(hass, platform, DOMAIN,
{'name': module_name, 'device': device_name},
config)
for idx, module_conf in enumerate(sensors):
load_module(CONF_SENSOR, idx, module_conf)
for idx, module_conf in enumerate(remotes):
load_module(CONF_REMOTE, idx, module_conf)
def _stop_kira(_event):
for receiver in hass.data[DOMAIN][CONF_SENSOR].values():
receiver.stop()
_LOGGER.info("Terminated receivers")
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_kira)
return True

View file

@ -0,0 +1,79 @@
"""
Support for Keene Electronics IR-IP devices.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/remote.kira/
"""
import logging
import functools as ft
import homeassistant.components.remote as remote
from homeassistant.helpers.entity import Entity
from homeassistant.const import (
STATE_UNKNOWN,
CONF_DEVICE,
CONF_NAME)
DOMAIN = 'kira'
_LOGGER = logging.getLogger(__name__)
CONF_REMOTE = "remote"
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the Kira platform."""
if discovery_info:
name = discovery_info.get(CONF_NAME)
device = discovery_info.get(CONF_DEVICE)
kira = hass.data[DOMAIN][CONF_REMOTE][name]
add_devices([KiraRemote(device, kira)])
return True
class KiraRemote(Entity):
"""Remote representation used to send commands to a Kira device."""
def __init__(self, name, kira):
"""Initialize KiraRemote class."""
_LOGGER.debug("KiraRemote device init started for: %s", name)
self._name = name
self._state = STATE_UNKNOWN
self._kira = kira
@property
def name(self):
"""Return the Kira device's name."""
return self._name
@property
def device_state_attributes(self):
"""Add platform specific attributes."""
return {}
@property
def is_on(self):
"""Return True. Power state doesn't apply to this device."""
return True
def update(self):
"""No-op."""
def send_command(self, **kwargs):
"""Send a command to one device."""
code_tuple = (kwargs.get(remote.ATTR_COMMAND),
kwargs.get(remote.ATTR_DEVICE))
_LOGGER.info("Sending Command: %s to %s", *code_tuple)
self._kira.sendCode(code_tuple)
def async_send_command(self, **kwargs):
"""Send a command to a device.
This method must be run in the event loop and returns a coroutine.
"""
return self.hass.loop.run_in_executor(
None, ft.partial(self.send_command, **kwargs))

View file

@ -1,7 +1,7 @@
# Describes the format for available remote services
turn_on:
description: Semds the Power On Command
description: Sends the Power On Command
fields:
entity_id:
@ -20,7 +20,7 @@ turn_off:
example: 'remote.family_room'
send_command:
description: Semds a single command to a single device
description: Sends a single command to a single device
fields:
entity_id:

View file

@ -0,0 +1,79 @@
"""KIRA interface to receive UDP packets from an IR-IP bridge."""
# pylint: disable=import-error
import logging
from homeassistant.const import (
CONF_DEVICE,
CONF_NAME,
STATE_UNKNOWN)
from homeassistant.helpers.entity import Entity
DOMAIN = 'kira'
_LOGGER = logging.getLogger(__name__)
ICON = 'mdi:remote'
CONF_SENSOR = "sensor"
# pylint: disable=unused-argument, too-many-function-args
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
"""Setup Kira sensor."""
if discovery_info is not None:
name = discovery_info.get(CONF_NAME)
device = discovery_info.get(CONF_DEVICE)
kira = hass.data[DOMAIN][CONF_SENSOR][name]
add_devices_callback([KiraReceiver(device, kira)])
class KiraReceiver(Entity):
"""Implementation of a Kira Receiver."""
def __init__(self, name, kira):
"""Initialize the sensor."""
self._name = name
self._state = STATE_UNKNOWN
self._device = STATE_UNKNOWN
kira.registerCallback(self._update_callback)
def _update_callback(self, code):
code_name, device = code
_LOGGER.info("Kira Code: %s", code_name)
self._state = code_name
self._device = device
self.schedule_update_ha_state()
@property
def name(self):
"""Return the name of the receiver."""
return self._name
@property
def icon(self):
"""Return icon."""
return ICON
@property
def state(self):
"""Return the state of the receiver."""
return self._state
@property
def device_state_attributes(self):
"""Return the state attributes of the device."""
attr = {}
attr[CONF_DEVICE] = self._device
return attr
@property
def should_poll(self) -> bool:
"""Entity should not be polled."""
return False
@property
def force_update(self) -> bool:
"""Kira should force updates. Repeated states have meaning."""
return True

View file

@ -565,6 +565,9 @@ pyiss==1.0.1
# homeassistant.components.remote.itach
pyitachip2ir==0.0.6
# homeassistant.components.kira
pykira==0.1.1
# homeassistant.components.sensor.kwb
pykwb==0.0.8

View file

@ -0,0 +1,57 @@
"""The tests for Kira sensor platform."""
import unittest
from unittest.mock import MagicMock
from homeassistant.components.remote import kira as kira
from tests.common import get_test_home_assistant
SERVICE_SEND_COMMAND = 'send_command'
TEST_CONFIG = {kira.DOMAIN: {
'devices': [{'host': '127.0.0.1',
'port': 17324}]}}
DISCOVERY_INFO = {
'name': 'kira',
'device': 'kira'
}
class TestKiraSensor(unittest.TestCase):
"""Tests the Kira Sensor platform."""
# pylint: disable=invalid-name
DEVICES = []
def add_devices(self, devices):
"""Mock add devices."""
for device in devices:
self.DEVICES.append(device)
def setUp(self):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
self.mock_kira = MagicMock()
self.hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}}
self.hass.data[kira.DOMAIN][kira.CONF_REMOTE]['kira'] = self.mock_kira
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
def test_service_call(self):
"""Test Kira's ability to send commands."""
kira.setup_platform(self.hass, TEST_CONFIG, self.add_devices,
DISCOVERY_INFO)
assert len(self.DEVICES) == 1
remote = self.DEVICES[0]
assert remote.name == 'kira'
command = "FAKE_COMMAND"
device = "FAKE_DEVICE"
commandTuple = (command, device)
remote.send_command(device=device, command=command)
self.mock_kira.sendCode.assert_called_with(commandTuple)

View file

@ -0,0 +1,59 @@
"""The tests for Kira sensor platform."""
import unittest
from unittest.mock import MagicMock
from homeassistant.components.sensor import kira as kira
from tests.common import get_test_home_assistant
TEST_CONFIG = {kira.DOMAIN: {
'sensors': [{'host': '127.0.0.1',
'port': 17324}]}}
DISCOVERY_INFO = {
'name': 'kira',
'device': 'kira'
}
class TestKiraSensor(unittest.TestCase):
"""Tests the Kira Sensor platform."""
# pylint: disable=invalid-name
DEVICES = []
def add_devices(self, devices):
"""Mock add devices."""
for device in devices:
self.DEVICES.append(device)
def setUp(self):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
mock_kira = MagicMock()
self.hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}}
self.hass.data[kira.DOMAIN][kira.CONF_SENSOR]['kira'] = mock_kira
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
# pylint: disable=protected-access
def test_kira_sensor_callback(self):
"""Ensure Kira sensor properly updates its attributes from callback."""
kira.setup_platform(self.hass, TEST_CONFIG, self.add_devices,
DISCOVERY_INFO)
assert len(self.DEVICES) == 1
sensor = self.DEVICES[0]
assert sensor.name == 'kira'
sensor.hass = self.hass
codeName = 'FAKE_CODE'
deviceName = 'FAKE_DEVICE'
codeTuple = (codeName, deviceName)
sensor._update_callback(codeTuple)
assert sensor.state == codeName
assert sensor.device_state_attributes == {kira.CONF_DEVICE: deviceName}

View file

@ -0,0 +1,85 @@
"""The tests for Home Assistant ffmpeg."""
import os
import shutil
import tempfile
import unittest
from unittest.mock import patch, MagicMock
import homeassistant.components.kira as kira
from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
TEST_CONFIG = {kira.DOMAIN: {
'sensors': [{'name': 'test_sensor',
'host': '127.0.0.1',
'port': 34293},
{'name': 'second_sensor',
'port': 29847}],
'remotes': [{'host': '127.0.0.1',
'port': 34293},
{'name': 'one_more',
'host': '127.0.0.1',
'port': 29847}]}}
KIRA_CODES = """
- name: test
code: "K 00FF"
- invalid: not_a_real_code
"""
class TestKiraSetup(unittest.TestCase):
"""Test class for kira."""
# pylint: disable=invalid-name
def setUp(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
_base_mock = MagicMock()
pykira = _base_mock.pykira
pykira.__file__ = 'test'
self._module_patcher = patch.dict('sys.modules', {
'pykira': pykira
})
self._module_patcher.start()
self.work_dir = tempfile.mkdtemp()
def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
self._module_patcher.stop()
shutil.rmtree(self.work_dir, ignore_errors=True)
def test_kira_empty_config(self):
"""Kira component should load a default sensor."""
setup_component(self.hass, kira.DOMAIN, {})
assert len(self.hass.data[kira.DOMAIN]['sensor']) == 1
def test_kira_setup(self):
"""Ensure platforms are loaded correctly."""
setup_component(self.hass, kira.DOMAIN, TEST_CONFIG)
assert len(self.hass.data[kira.DOMAIN]['sensor']) == 2
assert sorted(self.hass.data[kira.DOMAIN]['sensor'].keys()) == \
['kira', 'kira_1']
assert len(self.hass.data[kira.DOMAIN]['remote']) == 2
assert sorted(self.hass.data[kira.DOMAIN]['remote'].keys()) == \
['kira', 'kira_1']
def test_kira_creates_codes(self):
"""Kira module should create codes file if missing."""
code_path = os.path.join(self.work_dir, 'codes.yaml')
kira.load_codes(code_path)
assert os.path.exists(code_path), \
"Kira component didn't create codes file"
def test_load_codes(self):
"""Kira should ignore invalid codes."""
code_path = os.path.join(self.work_dir, 'codes.yaml')
with open(code_path, 'w') as code_file:
code_file.write(KIRA_CODES)
res = kira.load_codes(code_path)
assert len(res) == 1, "Expected exactly 1 valid Kira code"

View file