diff --git a/.coveragerc b/.coveragerc index dfdff90dd51..5b11aea6fd8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/kira.py b/homeassistant/components/kira.py new file mode 100644 index 00000000000..98d1228d541 --- /dev/null +++ b/homeassistant/components/kira.py @@ -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 diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py new file mode 100755 index 00000000000..3e816844a35 --- /dev/null +++ b/homeassistant/components/remote/kira.py @@ -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)) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 2023588fcc2..189377c503f 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -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: diff --git a/homeassistant/components/sensor/kira.py b/homeassistant/components/sensor/kira.py new file mode 100644 index 00000000000..232e50b85ed --- /dev/null +++ b/homeassistant/components/sensor/kira.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 6a684cfc310..cbaaaae7bfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/remote/test_kira.py b/tests/components/remote/test_kira.py new file mode 100644 index 00000000000..144504f8aa2 --- /dev/null +++ b/tests/components/remote/test_kira.py @@ -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) diff --git a/tests/components/sensor/test_kira.py b/tests/components/sensor/test_kira.py new file mode 100644 index 00000000000..093158cb25c --- /dev/null +++ b/tests/components/sensor/test_kira.py @@ -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} diff --git a/tests/components/test_kira.py b/tests/components/test_kira.py new file mode 100644 index 00000000000..a80d766c3fd --- /dev/null +++ b/tests/components/test_kira.py @@ -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" diff --git a/tests/testing_config/kira_codes.yaml b/tests/testing_config/kira_codes.yaml new file mode 100644 index 00000000000..e69de29bb2d