diff --git a/.coveragerc b/.coveragerc index 5e522016096acc..bd7ed28fe02239 100644 --- a/.coveragerc +++ b/.coveragerc @@ -99,6 +99,9 @@ omit = homeassistant/components/egardia.py homeassistant/components/*/egardia.py + homeassistant/components/elkm1/* + homeassistant/components/*/elkm1.py + homeassistant/components/enocean.py homeassistant/components/*/enocean.py diff --git a/homeassistant/components/alarm_control_panel/elkm1.py b/homeassistant/components/alarm_control_panel/elkm1.py new file mode 100644 index 00000000000000..8026a3736fb0e1 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/elkm1.py @@ -0,0 +1,200 @@ +""" +Each ElkM1 area will be created as a separate alarm_control_panel in HASS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.elkm1/ +""" + +import voluptuous as vol +import homeassistant.components.alarm_control_panel as alarm +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) +from homeassistant.components.elkm1 import ( + DOMAIN as ELK_DOMAIN, create_elk_entities, ElkEntity) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) + +DEPENDENCIES = [ELK_DOMAIN] + +SIGNAL_ARM_ENTITY = 'elkm1_arm' +SIGNAL_DISPLAY_MESSAGE = 'elkm1_display_message' + +ELK_ALARM_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Required(ATTR_CODE): vol.All(vol.Coerce(int), vol.Range(0, 999999)), +}) + +DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID, default=[]): cv.entity_ids, + vol.Optional('clear', default=2): vol.In([0, 1, 2]), + vol.Optional('beep', default=False): cv.boolean, + vol.Optional('timeout', default=0): vol.Range(min=0, max=65535), + vol.Optional('line1', default=''): cv.string, + vol.Optional('line2', default=''): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info): + """Set up the ElkM1 alarm platform.""" + elk = hass.data[ELK_DOMAIN]['elk'] + entities = create_elk_entities(hass, elk.areas, 'area', ElkArea, []) + async_add_entities(entities, True) + + def _dispatch(signal, entity_ids, *args): + for entity_id in entity_ids: + async_dispatcher_send( + hass, '{}_{}'.format(signal, entity_id), *args) + + def _arm_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + arm_level = _arm_services().get(service.service) + args = (arm_level, service.data.get(ATTR_CODE)) + _dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args) + + for service in _arm_services(): + hass.services.async_register( + alarm.DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA) + + def _display_message_service(service): + entity_ids = service.data.get(ATTR_ENTITY_ID, []) + data = service.data + args = (data['clear'], data['beep'], data['timeout'], + data['line1'], data['line2']) + _dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args) + + hass.services.async_register( + alarm.DOMAIN, 'elkm1_alarm_display_message', + _display_message_service, DISPLAY_MESSAGE_SERVICE_SCHEMA) + + +def _arm_services(): + from elkm1_lib.const import ArmLevel + + return { + 'elkm1_alarm_arm_vacation': ArmLevel.ARMED_VACATION.value, + 'elkm1_alarm_arm_home_instant': ArmLevel.ARMED_STAY_INSTANT.value, + 'elkm1_alarm_arm_night_instant': ArmLevel.ARMED_NIGHT_INSTANT.value, + } + + +class ElkArea(ElkEntity, alarm.AlarmControlPanel): + """Representation of an Area / Partition within the ElkM1 alarm panel.""" + + def __init__(self, element, elk, elk_data): + """Initialize Area as Alarm Control Panel.""" + super().__init__('alarm_control_panel', element, elk, elk_data) + self._changed_by_entity_id = '' + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes.""" + await super().async_added_to_hass() + for keypad in self._elk.keypads: + keypad.add_callback(self._watch_keypad) + async_dispatcher_connect( + self.hass, '{}_{}'.format(SIGNAL_ARM_ENTITY, self.entity_id), + self._arm_service) + async_dispatcher_connect( + self.hass, '{}_{}'.format(SIGNAL_DISPLAY_MESSAGE, self.entity_id), + self._display_message) + + def _watch_keypad(self, keypad, changeset): + if keypad.area != self._element.index: + return + if changeset.get('last_user') is not None: + self._changed_by_entity_id = self.hass.data[ + ELK_DOMAIN]['keypads'].get(keypad.index, '') + self.async_schedule_update_ha_state(True) + + @property + def code_format(self): + """Return the alarm code format.""" + return '^[0-9]{4}([0-9]{2})?$' + + @property + def state(self): + """Return the state of the element.""" + return self._state + + @property + def device_state_attributes(self): + """Attributes of the area.""" + from elkm1_lib.const import AlarmState, ArmedStatus, ArmUpState + + attrs = self.initial_attrs() + elmt = self._element + attrs['is_exit'] = elmt.is_exit + attrs['timer1'] = elmt.timer1 + attrs['timer2'] = elmt.timer2 + if elmt.armed_status is not None: + attrs['armed_status'] = \ + ArmedStatus(elmt.armed_status).name.lower() + if elmt.arm_up_state is not None: + attrs['arm_up_state'] = ArmUpState(elmt.arm_up_state).name.lower() + if elmt.alarm_state is not None: + attrs['alarm_state'] = AlarmState(elmt.alarm_state).name.lower() + attrs['changed_by_entity_id'] = self._changed_by_entity_id + return attrs + + def _element_changed(self, element, changeset): + from elkm1_lib.const import ArmedStatus + + elk_state_to_hass_state = { + ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED, + ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY, + ArmedStatus.ARMED_STAY.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_STAY_INSTANT.value: STATE_ALARM_ARMED_HOME, + ArmedStatus.ARMED_TO_NIGHT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_NIGHT_INSTANT.value: STATE_ALARM_ARMED_NIGHT, + ArmedStatus.ARMED_TO_VACATION.value: STATE_ALARM_ARMED_AWAY, + } + + if self._element.alarm_state is None: + self._state = None + elif self._area_is_in_alarm_state(): + self._state = STATE_ALARM_TRIGGERED + elif self._entry_exit_timer_is_running(): + self._state = STATE_ALARM_ARMING \ + if self._element.is_exit else STATE_ALARM_PENDING + else: + self._state = elk_state_to_hass_state[self._element.armed_status] + + def _entry_exit_timer_is_running(self): + return self._element.timer1 > 0 or self._element.timer2 > 0 + + def _area_is_in_alarm_state(self): + from elkm1_lib.const import AlarmState + + return self._element.alarm_state >= AlarmState.FIRE_ALARM.value + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + self._element.disarm(int(code)) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_STAY.value, int(code)) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_AWAY.value, int(code)) + + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + from elkm1_lib.const import ArmLevel + + self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code)) + + async def _arm_service(self, arm_level, code): + self._element.arm(arm_level, code) + + async def _display_message(self, clear, beep, timeout, line1, line2): + """Display a message on all keypads for the area.""" + self._element.display_message(clear, beep, timeout, line1, line2) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 391de2033c7718..7918631464fee3 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -79,3 +79,55 @@ ifttt_push_alarm_state: state: description: The state to which the alarm control panel has to be set. example: 'armed_night' + +elkm1_alarm_arm_vacation: + description: Arm the ElkM1 in vacation mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_home_instant: + description: Arm the ElkM1 in home instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_arm_night_instant: + description: Arm the ElkM1 in night instant mode. + fields: + entity_id: + description: Name of alarm control panel to arm. + example: 'alarm_control_panel.main' + code: + description: An code to arm the alarm control panel. + example: 1234 + +elkm1_alarm_display_message: + description: Display a message on all of the ElkM1 keypads for an area. + fields: + entity_id: + description: Name of alarm control panel to display messages on. + example: 'alarm_control_panel.main' + clear: + description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2 + example: 1 + beep: + description: 0=no beep, 1=beep; default 0 + example: 1 + timeout: + description: Time to display message, 0=forever, max 65535, default 0 + example: 4242 + line1: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: The answer to life, + line2: + description: Up to 16 characters of text (truncated if too long). Default blank. + example: the universe, and everything. diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py new file mode 100644 index 00000000000000..505280d4f266b0 --- /dev/null +++ b/homeassistant/components/elkm1/__init__.py @@ -0,0 +1,212 @@ +""" +Support the ElkM1 Gold and ElkM1 EZ8 alarm / integration panels. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/elkm1/ +""" + +import logging +import re + +import voluptuous as vol +from homeassistant.const import ( + CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, + CONF_TEMPERATURE_UNIT, CONF_USERNAME, TEMP_FAHRENHEIT) +from homeassistant.core import HomeAssistant, callback # noqa +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType # noqa + +DOMAIN = "elkm1" + +REQUIREMENTS = ['elkm1-lib==0.7.10'] + +CONF_AREA = 'area' +CONF_COUNTER = 'counter' +CONF_KEYPAD = 'keypad' +CONF_OUTPUT = 'output' +CONF_SETTING = 'setting' +CONF_TASK = 'task' +CONF_THERMOSTAT = 'thermostat' +CONF_PLC = 'plc' +CONF_ZONE = 'zone' +CONF_ENABLED = 'enabled' + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_DOMAINS = ['alarm_control_panel'] + + +def _host_validator(config): + """Validate that a host is properly configured.""" + if config[CONF_HOST].startswith('elks://'): + if CONF_USERNAME not in config or CONF_PASSWORD not in config: + raise vol.Invalid("Specify username and password for elks://") + elif not config[CONF_HOST].startswith('elk://') and not config[ + CONF_HOST].startswith('serial://'): + raise vol.Invalid("Invalid host URL") + return config + + +def _elk_range_validator(rng): + def _housecode_to_int(val): + match = re.search(r'^([a-p])(0[1-9]|1[0-6]|[1-9])$', val.lower()) + if match: + return (ord(match.group(1)) - ord('a')) * 16 + int(match.group(2)) + raise vol.Invalid("Invalid range") + + def _elk_value(val): + return int(val) if val.isdigit() else _housecode_to_int(val) + + vals = [s.strip() for s in str(rng).split('-')] + start = _elk_value(vals[0]) + end = start if len(vals) == 1 else _elk_value(vals[1]) + return (start, end) + + +CONFIG_SCHEMA_SUBDOMAIN = vol.Schema({ + vol.Optional(CONF_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator], + vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator], +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=''): cv.string, + vol.Optional(CONF_PASSWORD, default=''): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT, default=TEMP_FAHRENHEIT): + cv.temperature_unit, + vol.Optional(CONF_AREA): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE): CONFIG_SCHEMA_SUBDOMAIN, + }, + _host_validator, + ) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: + """Set up the Elk M1 platform.""" + from elkm1_lib.const import Max + import elkm1_lib as elkm1 + + configs = { + CONF_AREA: Max.AREAS.value, + CONF_COUNTER: Max.COUNTERS.value, + CONF_KEYPAD: Max.KEYPADS.value, + CONF_OUTPUT: Max.OUTPUTS.value, + CONF_PLC: Max.LIGHTS.value, + CONF_SETTING: Max.SETTINGS.value, + CONF_TASK: Max.TASKS.value, + CONF_THERMOSTAT: Max.THERMOSTATS.value, + CONF_ZONE: Max.ZONES.value, + } + + def _included(ranges, set_to, values): + for rng in ranges: + if not rng[0] <= rng[1] <= len(values): + raise vol.Invalid("Invalid range {}".format(rng)) + values[rng[0]-1:rng[1]] = [set_to] * (rng[1] - rng[0] + 1) + + conf = hass_config[DOMAIN] + config = {'temperature_unit': conf[CONF_TEMPERATURE_UNIT]} + config['panel'] = {'enabled': True, 'included': [True]} + + for item, max_ in configs.items(): + config[item] = {'enabled': conf[item][CONF_ENABLED], + 'included': [not conf[item]['include']] * max_} + try: + _included(conf[item]['include'], True, config[item]['included']) + _included(conf[item]['exclude'], False, config[item]['included']) + except (ValueError, vol.Invalid) as err: + _LOGGER.error("Config item: %s; %s", item, err) + return False + + elk = elkm1.Elk({'url': conf[CONF_HOST], 'userid': conf[CONF_USERNAME], + 'password': conf[CONF_PASSWORD]}) + elk.connect() + + hass.data[DOMAIN] = {'elk': elk, 'config': config, 'keypads': {}} + for component in SUPPORTED_DOMAINS: + hass.async_create_task( + discovery.async_load_platform(hass, component, DOMAIN)) + + return True + + +def create_elk_entities(hass, elk_elements, element_type, class_, entities): + """Create the ElkM1 devices of a particular class.""" + elk_data = hass.data[DOMAIN] + if elk_data['config'][element_type]['enabled']: + elk = elk_data['elk'] + for element in elk_elements: + if elk_data['config'][element_type]['included'][element.index]: + entities.append(class_(element, elk, elk_data)) + return entities + + +class ElkEntity(Entity): + """Base class for all Elk entities.""" + + def __init__(self, platform, element, elk, elk_data): + """Initialize the base of all Elk devices.""" + self._elk = elk + self._element = element + self._state = None + self._temperature_unit = elk_data['config']['temperature_unit'] + self._unique_id = 'elkm1_{}'.format( + self._element.default_name('_').lower()) + + @property + def name(self): + """Name of the element.""" + return self._element.name + + @property + def unique_id(self): + """Return unique id of the element.""" + return self._unique_id + + @property + def should_poll(self) -> bool: + """Don't poll this device.""" + return False + + @property + def device_state_attributes(self): + """Return the default attributes of the element.""" + return {**self._element.as_dict(), **self.initial_attrs()} + + @property + def available(self): + """Is the entity available to be updated.""" + return self._elk.is_connected() + + def initial_attrs(self): + """Return the underlying element's attributes as a dict.""" + attrs = {} + attrs['index'] = self._element.index + 1 + return attrs + + def _element_changed(self, element, changeset): + raise NotImplementedError() + + @callback + def _element_callback(self, element, changeset): + """Handle callback from an Elk element that has changed.""" + self._element_changed(element, changeset) + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callback for ElkM1 changes and update entity state.""" + self._element.add_callback(self._element_callback) + self._element_callback(self._element, {}) diff --git a/requirements_all.txt b/requirements_all.txt index eeee16a613883f..661d24680e6e80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -317,6 +317,9 @@ einder==0.3.1 # homeassistant.components.sensor.eliqonline eliqonline==1.0.14 +# homeassistant.components.elkm1 +elkm1-lib==0.7.10 + # homeassistant.components.enocean enocean==0.40