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:
parent
4cdf0b4969
commit
9c4bc2a47f
10 changed files with 509 additions and 2 deletions
|
@ -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
|
||||
|
||||
|
|
142
homeassistant/components/kira.py
Normal file
142
homeassistant/components/kira.py
Normal 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
|
79
homeassistant/components/remote/kira.py
Executable file
79
homeassistant/components/remote/kira.py
Executable 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))
|
|
@ -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:
|
||||
|
|
79
homeassistant/components/sensor/kira.py
Normal file
79
homeassistant/components/sensor/kira.py
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
57
tests/components/remote/test_kira.py
Normal file
57
tests/components/remote/test_kira.py
Normal 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)
|
59
tests/components/sensor/test_kira.py
Normal file
59
tests/components/sensor/test_kira.py
Normal 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}
|
85
tests/components/test_kira.py
Normal file
85
tests/components/test_kira.py
Normal 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"
|
0
tests/testing_config/kira_codes.yaml
Normal file
0
tests/testing_config/kira_codes.yaml
Normal file
Loading…
Add table
Add a link
Reference in a new issue