diff --git a/LICENSE b/LICENSE index 5617d96..b6ceb63 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Mark Coombes +Copyright (c) 2021 Mark Coombes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 03afd6b..988c307 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,91 @@ -# HomeSeer HS3 Custom Component for Home Assistant +# HomeSeer Custom Integration for Home Assistant -## Supported Devices +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WBGSD2WU6944G) -Z-Wave devices of the following types should create entities in Home Assistant: -- Z-Wave Barrier Operator (as Home Assistant cover) -- Z-Wave Battery (as Home Assistant sensor) -- Z-Wave Door Lock (as Home Assistant lock) -- Z-Wave Sensor Binary (as Home Assistant binary sensor) -- Z-Wave Sensor Multilevel (as Home Assistant sensor) -- Z-Wave Switch (as Home Assistant switch) -- Z-Wave Switch Binary (as Home Assistant switch) -- Z-Wave Switch Multilevel (as Home Assistant light or cover) -- Z-Wave Central Scene (as Home Assistant event - see below) -- Z-Wave Temperature (as Home Assistant sensor) -- Z-Wave Relative Humidity (as Home Assistant sensor) -- Z-Wave Luminance (as Home Assistant sensor) -- Z-Wave Fan State for HVAC (as Home Assistant sensor) -- Z-Wave Operating State for HVAC (as Home Assistant sensor) +[Home Assistant](https://home-assistant.io/) custom integration supporting [HomeSeer](www.homeseer.com) Smart Home Software (HS3 and HS4). -HomeSeer Events will be created as Home Assistant scenes (triggering the scene in Home Assistant will run the HomeSeer event). +This integration will create Home Assistant entities for the following types of devices in HomeSeer by default: -### Central Scene devices +- "Switchable" devices (i.e. devices with On/Off controls) as a Home Assistant switch entity +- "Dimmable" devices (i.e. devices with On/Off and Dim controls) as a Home Assistant light entity +- "Lockable" devices (i.e. devices with Lock/Unlock controls) as a Home Assistant lock entity +- "Status" devices (i.e. devices with no controls) as a Home Assistant sensor entity -HomeSeer devices of the type "Z-Wave Central Scene" will not create an entity in Home Assistant; instead, when a Central Scene device is updated in HomeSeer, this component will fire an event on the Home Assistant event bus which can be used in Automations. +The type of entity created can also depend on whether a "quirk" has been added for the device (see below) and options chosen by the user during configuration. This custom integration currently supports creating entities for the following Home Assistant platforms: binary sensor, cover, light, lock, scene, sensor, switch. Fan and Media Player entities will be added in a future update! -`event_type`: homeseer_event -`event_data`: -- `id`: Device Ref of the Central Scene device in HomeSeer. -- `event`: Numeric value of the Central Scene device in HomeSeer for a given event. +## Pre-Installation +This integration communicates with HomeSeer via both JSON and ASCII. You must enable control using JSON and ASCII commands in Tools/Setup/Network in the HomeSeer web interface. -## Install +## Installation +This custom integration must be installed for it to be loaded by Home Assistant. -0. Enable the ASCII connection in HomeSeer (required to receive device updates in Home Assistant). -1. Create the directory `custom_components` inside your Home Assistant config directory. -2. `cd` into the `custom_components` directory and do `git clone https://github.com/marthoc/homeseer`. -3. Add the below config to your configuration.yaml and restart Home Assistant. -4. Problems with certain devices (i.e. not supported yet) will be reported in the debug logs for the component/pyHS3. +_The recommended installation method is via [HACS](https://hacs.xyz)._ -## Upgrade +### HACS -0. Stop Home Assistant -1. `cd` into the `custom_components/homeseer` directory and do `git pull`. -2. Start Home Assistant +1. Add https://github.com/marthoc/homeseer as a custom repository in HACS. +2. Search for "HomeSeer" under "Integrations" in HACS. +3. Click "Install". +4. Proceed with Configuration (see below). -## configuration.yaml example +### Manual -```yaml -homeseer: - host: 192.168.1.10 - namespace: homeseer - http_port: 80 - ascii_port: 11000 - username: default - password: default - name_template: '{{ device.name }}' - allow_events: True - forced_covers: [ 10, 20, 30 ] - allowed_event_groups: [ "Light Events", "Lock Events" ] -``` -|Parameter|Description|Required/Optional| -|---------|-----------|-----------------| -|host|IP address of the HomeSeer HS3 HomeTroller|Required| -|namespace|Unique string identifying the HomeSeer instance|Required| -|http_port|HTTP port of the HomeTroller|Optional, default 80| -|ascii_port|ASCII port of the HomeTroller|Optional, default 11000| -|username|Username of the user to connect to the HomeTroller|Optional, default "default"| -|password|Password of the user to connect to the HomeTroller|Optional, default "default"| -|name_template|Jinja2 template for naming devices|Optional, default "{{ device.location2 }} {{ device.location }} {{ device.name }}"| -|allow_events|Create Home Assistant scenes for HomeSeer events|Optional, default True| -|forced_covers|List of refs of Z-Wave Switch Multilevels that should be represented in HA as covers|Optional, default all ZWSM to lights| -|allowed_event_groups|List of names of HomeSeer event groups to import; all other groups will be ignored|Optional, default all groups imported| - -### Namespace - -In order to generate unique ids for entities to enable support for the entity registry (most importantly, allowing users to rename entities and change entity ids from the UI), a unique string is required. Namespace can be any string you like. If this string changes, all entities will generate new entries in the entity registry, so only change this string if you absolutely know what you are doing. - -### Name Template - -The HomeSeer integration will generate default entity names and ids in HomeAssistant when devices are added for the first time. -By default, the generated name is of the form "location2 location name". You can customize the name generation by -specifying your own Jinja2 template in "name_template". This template will only have an effect on newly added devices and -won't change the names of existing entities. - -Example: -- HomeSeer location2 "Main Floor" -- HomeSeer location "Living Room" -- HomeSeer device name "Lamp" - -Result: -- name_template = "{{ device.name }}": Home Assistant entity will be called "Lamp" -- name_template = "{{ device.location }} - {{ device.name }}": Home Assistant entity will be called "Living Room - Lamp" -- name_template = "HomeSeer - {{ device.name }}": Home Assistant entity will be called "HomeSeer - Lamp" +1. Create a `custom_components` director in your Home Assistant configuration directory. +2. Download the latest release from the GitHub "Releases" page. +3. Copy the custom_components/homeseer directory from the archive into the custom_components directory in your Home Assistant configuration directory. +4. Restart Home Assistant and proceed with Configuration (see below). + +## Configuration + +To enable the integration, add it from the Configuration - Integrations menu in Home Assistant: click `+`, then click "HomeSeer". + +The following options must be configured at the first stage of the configuration: + +|Parameter|Description|Default| +|---------|-----------|-------| +|Host|The IP address of the HomeSeer instance.|N/A| +|Username|The username used to log into HomeSeer.|"default"| +|Password|The password used to log into HomeSeer.|"default"| +|HTTP Port|The HTTP port of the HomeSeer instance.|80| +|ASCII Port|The ASCII port of the HomeSeer instance.|11000| + +After clicking submit, the following additional options will be presented to the user: + +|Parameter|Description|Default| +|---------|-----------|-------| +|Namespace|A unique string identifying this HomeSeer instance. You may input any string. (This will be used in a future release to allow connections to multiple HomeSeer instances.)|"homeseer"| +|Entity Name Template|A template (Jinja2 format) describing how Home Assistant entities will be named. Default format is "location2 location name".|"{{ device.location2 }} {{ device.location }} {{ device.name }}"| +|Create Scenes from HomeSeer Events?|If this box is ticked, a Home Assistant Scene will be created for each Event in HomeSeer. Events can be filtered by group during a later stage of the configuration.|True| + +After clicking submit, the user will be presented with successive dialogs to select: +- Technology interfaces present in HomeSeer to allow in Home Assistant. The type "HomeSeer" represents devices native to HomeSeer such as virtual devices. (Note: Z-Wave is best-supported, but most devices from other interfaces should 'just work'.) Deselecting an interface name here means that devices from that interface will NOT create Entities in Home Assistant. +- If the user has ticked "Create scenes from HomeSeer Events?", the user will be able to select Event Groups in HomeSeer to allow in Home Assistant. Selecting any groups here will allow ONLY those groups; selecting no groups here will allow ALL event groups. The selected groups (or all groups) will create a Home Assistant Scene for each Event in that group. +- Switches and Dimmers from HomeSeer to be represented as Covers (i.e. blinds or garage doors) in Home Assistant. Device refs selected here will not create a Switch or Light entity in Home Assistant but instead a garage door or blind. + +## Quirks + +Certain devices in HomeSeer should be represented as an entity other than their HomeSeer features would suggest. Quirks exist in this integration to allow "forcing" a certain type of device to be a certain Home Assistant entity. Currently, there are quirks for the following types of devices: +- Z-Wave Barrier Operator as "cover" (i.e. a garage door) +- Z-Wave Central Scene as Home Assistant events (see below) +- Z-Wave Sensor Binary as "binary sensor" + +Further quirks can be requested by opening an issue in this repository with information about the device (and ideally, debug logs from libhomeseer or the integration itself which will contain the information necessary to create the quirk). + +## Home Assistant events + +Certain HomeSeer devices should be represented as a Home Assistant event - no entity will be created for these devices. Instead, when one of these devices are updated in HomeSeer, this integration will fire an event on the Home Assistant event bus which can be used to trigger a Home Assistant Automation. + +The event will contain the following parameters: + +`event_type`: homeseer_event +`event_data`: +- `id`: Device Ref of the Central Scene device in HomeSeer. +- `event`: Numeric value of the device in HomeSeer for a given event. + +Currently, the following types of HomeSeer devices will fire events in Home Assistant: +- Z-Wave Central Scene + +Support for other "stateless" devices (i.e. remotes) such as these can be added in future updates. Please request support by opening an issue in this repository. ## Services @@ -106,4 +101,22 @@ Allows the user to set any value on a HomeSeer device. |ref|Ref corresponding to the HomeSeer device |Integer|True| |value|Value to set the device to (integer) |Integer|True| +## Support + +Please open an issue on this repository for any feature requests or bug reports. Some issues may be moved to the upstream repo marthoc/libhomeseer if the request or bug relates to the underlying python library. + +Debug logs are essential when requesting new features or for tracking down bugs. You can enable debug logging for the integration by adding the following to your configuration.yaml: + +```yaml +logger: + default: critical + custom_components.homeseer: debug + libhomeseer: debug +``` +The above entry will essentially silence the logs except for debug output from this integration and the underlying library. + +The only sensitive or personally identifying information contained in the debug logs will be your HomeSeer username, if you have supplied a value other than "default" (no passwords or external IPs are included in the debug logs). + +## Caveats +The HomeSeer JSON API exposes only limited information about the devices present in HomeSeer. Requests for certain features may be declined due to the required data not being present in the API response. diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 9960940..0000000 --- a/__init__.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Custom component for interacting with a HomeSeer HomeTroller or HS3 software installation. - -For more details about this custom component, please refer to the documentation at -https://github.com/marthoc/homeseer -""" -import asyncio - -import voluptuous as vol -from pyhs3 import HomeTroller, HASS_EVENTS, STATE_LISTENING - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_ID, - CONF_PASSWORD, - CONF_USERNAME, -) -from homeassistant.core import EventOrigin -from homeassistant.helpers import aiohttp_client, discovery - -from .const import ( - _LOGGER, - ATTR_REF, - ATTR_VALUE, - CONF_ALLOWED_EVENT_GROUPS, - CONF_ALLOW_EVENTS, - CONF_ASCII_PORT, - CONF_FORCED_COVERS, - CONF_HTTP_PORT, - CONF_NAME_TEMPLATE, - CONF_NAMESPACE, - DEFAULT_ALLOWED_EVENT_GROUPS, - DEFAULT_ALLOW_EVENTS, - DEFAULT_ASCII_PORT, - DEFAULT_FORCED_COVERS, - DEFAULT_HTTP_PORT, - DEFAULT_PASSWORD, - DEFAULT_USERNAME, - DEFAULT_NAME_TEMPLATE, - DOMAIN, - HOMESEER_PLATFORMS, -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_NAMESPACE): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_HTTP_PORT, default=DEFAULT_HTTP_PORT): cv.port, - vol.Optional(CONF_ASCII_PORT, default=DEFAULT_ASCII_PORT): cv.port, - vol.Optional( - CONF_NAME_TEMPLATE, default=DEFAULT_NAME_TEMPLATE - ): cv.template, - vol.Optional( - CONF_ALLOW_EVENTS, default=DEFAULT_ALLOW_EVENTS - ): cv.boolean, - vol.Optional( - CONF_ALLOWED_EVENT_GROUPS, default=DEFAULT_ALLOWED_EVENT_GROUPS - ): cv.ensure_list, - vol.Optional( - CONF_FORCED_COVERS, default=DEFAULT_FORCED_COVERS - ): cv.ensure_list, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_CONTROL_DEVICE_BY_VALUE = "control_device_by_value" - -SERVICE_CONTROL_DEVICE_BY_VALUE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_REF): cv.positive_int, - vol.Required(ATTR_VALUE): cv.positive_int, - } -) - - -async def async_setup(hass, config): - """Set up the HomeSeer component.""" - config = config.get(DOMAIN) - host = config[CONF_HOST] - namespace = config[CONF_NAMESPACE] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - http_port = config[CONF_HTTP_PORT] - ascii_port = config[CONF_ASCII_PORT] - name_template = config[CONF_NAME_TEMPLATE] - allow_events = config[CONF_ALLOW_EVENTS] - allowed_event_groups = config[CONF_ALLOWED_EVENT_GROUPS] - forced_covers = config[CONF_FORCED_COVERS] - - name_template.hass = hass - - homeseer = HSConnection( - hass, host, username, password, http_port, ascii_port, namespace, name_template - ) - - await homeseer.api.initialize() - if len(homeseer.devices) == 0 and len(homeseer.events) == 0: - _LOGGER.error("No supported HomeSeer devices found, aborting component setup.") - return False - - await homeseer.start() - i = 0 - while homeseer.api.state != STATE_LISTENING: - if i < 3: - i += 1 - await asyncio.sleep(1) - elif i == 3: - _LOGGER.error( - "Failed to connect to HomeSeer ASCII server, aborting component setup." - ) - await homeseer.stop() - return False - _LOGGER.info(f"Connected to HomeSeer ASCII server at {host}:{ascii_port}") - - homeseer.add_remotes() - - if not allow_events and len(allowed_event_groups) == 0: - HOMESEER_PLATFORMS.remove("scene") - - for platform in HOMESEER_PLATFORMS: - hass.async_create_task( - discovery.async_load_platform( - hass, - platform, - DOMAIN, - { - CONF_ALLOWED_EVENT_GROUPS: allowed_event_groups, - CONF_FORCED_COVERS: forced_covers, - }, - config, - ) - ) - - hass.data[DOMAIN] = homeseer - - hass.bus.async_listen_once("homeassistant_stop", homeseer.stop) - - async def control_device_by_value(call): - ref = call.data[ATTR_REF] - value = call.data[ATTR_VALUE] - - await homeseer.api.control_device_by_value(ref, value) - - hass.services.async_register( - DOMAIN, - SERVICE_CONTROL_DEVICE_BY_VALUE, - control_device_by_value, - schema=SERVICE_CONTROL_DEVICE_BY_VALUE_SCHEMA, - ) - - return True - - -class HSConnection: - """Manages a connection between HomeSeer and Home Assistant.""" - - def __init__( - self, - hass, - host, - username, - password, - http_port, - ascii_port, - namespace, - name_template, - ): - self._hass = hass - self._session = aiohttp_client.async_get_clientsession(self._hass) - self.api = HomeTroller( - host, - self._session, - username=username, - password=password, - http_port=http_port, - ascii_port=ascii_port, - ) - self._namespace = namespace - self._name_template = name_template - self.remotes = [] - - @property - def devices(self): - return self.api.devices.values() - - @property - def events(self): - return self.api.events - - @property - def namespace(self): - return self._namespace - - @property - def name_template(self): - return self._name_template - - async def start(self): - await self.api.start_listener() - - async def stop(self, *args): - await self.api.stop_listener() - - def add_remotes(self): - for device in self.devices: - if device.device_type_string in HASS_EVENTS: - self.remotes.append(HSRemote(self._hass, device)) - _LOGGER.info( - f"Added HomeSeer remote-type device: {device.name} (Ref: {device.ref})" - ) - - -class HSRemote: - """Link remote-type devices that should fire events rather than create entities to Home Assistant.""" - - def __init__(self, hass, device): - self._hass = hass - self._device = device - self._device.register_update_callback( - self.update_callback, suppress_on_reconnect=True - ) - self._event = f"homeseer_{CONF_EVENT}" - - def update_callback(self): - """Fire the event.""" - data = {CONF_ID: self._device.ref, CONF_EVENT: self._device.value} - self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/binary_sensor.py b/binary_sensor.py deleted file mode 100644 index f469ed0..0000000 --- a/binary_sensor.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Support for HomeSeer binary-type devices. -""" - -from pyhs3 import HASS_BINARY_SENSORS, STATE_LISTENING - -from homeassistant.components.binary_sensor import BinarySensorEntity - -from .const import _LOGGER, DOMAIN - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer binary-type devices.""" - binary_sensor_devices = [] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string in HASS_BINARY_SENSORS: - dev = HSBinarySensor(device, homeseer) - binary_sensor_devices.append(dev) - _LOGGER.info(f"Added HomeSeer binary-sensor-type device: {dev.name}") - - async_add_entities(binary_sensor_devices) - - -class HSBinarySensor(BinarySensorEntity): - """Representation of a HomeSeer binary-type device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - - @property - def available(self): - """Return True if the HomeSeer connection is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_on(self): - """Return true if device is on.""" - return self._device.value > 0 - - async def async_added_to_hass(self): - """Register value update callback.""" - self._device.register_update_callback(self.async_schedule_update_ha_state) diff --git a/cover.py b/cover.py deleted file mode 100644 index e721561..0000000 --- a/cover.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -Support for HomeSeer cover-type devices. -""" - -from pyhs3 import ( - STATE_LISTENING, - DEVICE_ZWAVE_BARRIER_OPERATOR, - DEVICE_ZWAVE_SWITCH_MULTILEVEL, -) - -from homeassistant.components.cover import ( - CoverEntity, - ATTR_POSITION, - DEVICE_CLASS_BLIND, - DEVICE_CLASS_GARAGE, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, -) -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING - -from .const import _LOGGER, DOMAIN, CONF_FORCED_COVERS - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer cover-type devices.""" - cover_devices = [] - forced_covers = discovery_info[CONF_FORCED_COVERS] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string == DEVICE_ZWAVE_BARRIER_OPERATOR: - """Device is a garage-door opener.""" - dev = HSGarage(device, homeseer) - cover_devices.append(dev) - _LOGGER.info(f"Added HomeSeer garage-type device: {dev.name}") - elif device.device_type_string == DEVICE_ZWAVE_SWITCH_MULTILEVEL and int(device.ref) in forced_covers: - """Device is a blind.""" - dev = HSBlind(device, homeseer) - cover_devices.append(dev) - _LOGGER.info(f"Added HomeSeer blind-type device: {dev.name}") - - async_add_entities(cover_devices) - - -class HSCover(CoverEntity): - """Base representation of a HomeSeer cover-type device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - - @property - def available(self): - """Return whether the device is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def should_poll(self): - """No polling needed.""" - return False - - async def async_added_to_hass(self): - """Register value update callback.""" - self._device.register_update_callback(self.async_schedule_update_ha_state) - - -class HSGarage(HSCover): - """Representation of a garage door opener device.""" - - @property - def supported_features(self): - """Return the features supported by the device.""" - features = SUPPORT_OPEN | SUPPORT_CLOSE - return features - - @property - def device_class(self): - """Return the device class for the device.""" - return DEVICE_CLASS_GARAGE - - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._device.current_state == STATE_OPENING - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._device.current_state == STATE_CLOSING - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - return self._device.current_state == STATE_CLOSED - - async def async_open_cover(self, **kwargs): - await self._device.open() - - async def async_close_cover(self, **kwargs): - await self._device.close() - - -class HSBlind(HSCover): - """Representation of a window-covering device.""" - - @property - def supported_features(self): - """Return the features supported by the device.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - - @property - def device_class(self): - """Return the device class for the device.""" - return DEVICE_CLASS_BLIND - - @property - def current_cover_position(self): - """Return the current position of the cover.""" - return int(self._device.dim_percent * 100) - - @property - def is_closed(self): - """Return if the cover is closed or not.""" - return not self._device.is_on - - async def async_open_cover(self, **kwargs): - await self._device.on() - - async def async_close_cover(self, **kwargs): - await self._device.off() - - async def async_set_cover_position(self, **kwargs): - await self._device.dim(kwargs.get(ATTR_POSITION, 0)) diff --git a/custom_components/homeseer/__init__.py b/custom_components/homeseer/__init__.py new file mode 100644 index 0000000..fc78eca --- /dev/null +++ b/custom_components/homeseer/__init__.py @@ -0,0 +1,145 @@ +""" +Custom integration for interacting with a HomeSeer software installation. +For more details please refer to the documentation at https://github.com/marthoc/homeseer +""" + +import asyncio +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +) + +from homeassistant.helpers import template + +from .const import ( + ATTR_REF, + ATTR_VALUE, + CONF_ALLOWED_EVENT_GROUPS, + CONF_ALLOWED_INTERFACES, + CONF_ALLOW_EVENTS, + CONF_ASCII_PORT, + CONF_FORCED_COVERS, + CONF_HTTP_PORT, + CONF_NAME_TEMPLATE, + CONF_NAMESPACE, + DOMAIN, + HOMESEER_PLATFORMS, +) +from .homeseer import HomeSeerBridge + +_LOGGER = logging.getLogger(__name__) + +SERVICE_CONTROL_DEVICE_BY_VALUE = "control_device_by_value" + +SERVICE_CONTROL_DEVICE_BY_VALUE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_REF): cv.positive_int, + vol.Required(ATTR_VALUE): cv.positive_int, + } +) + + +async def async_setup(hass, config): + """HomeSeer is configured via config entry.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up a HomeSeer config entry.""" + config = config_entry.data + + host = config[CONF_HOST] + namespace = config[CONF_NAMESPACE] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + http_port = config[CONF_HTTP_PORT] + ascii_port = config[CONF_ASCII_PORT] + name_template = template.Template(str(config[CONF_NAME_TEMPLATE])) + allow_events = config[CONF_ALLOW_EVENTS] + allowed_event_groups = config[CONF_ALLOWED_EVENT_GROUPS] + forced_covers = config[CONF_FORCED_COVERS] + allowed_interfaces = config[CONF_ALLOWED_INTERFACES] + + name_template.hass = hass + + bridge = HomeSeerBridge( + hass, + host, + username, + password, + http_port, + ascii_port, + namespace, + name_template, + allowed_event_groups, + forced_covers, + allowed_interfaces, + ) + + try: + if not await asyncio.wait_for(bridge.setup(), 60): + _LOGGER.error( + f"No supported HomeSeer devices or events found, aborting entry setup for {host}." + ) + except asyncio.TimeoutError: + _LOGGER.error(f"Could not connect to HomeSeer at {host}, aborting entry setup.") + return False + + await bridge.start() + attempts = 0 + while not bridge.api.available: + if attempts < 5: + attempts += 1 + await asyncio.sleep(5) + continue + _LOGGER.error( + f"Failed to connect to HomeSeer ASCII connection at {host}:{ascii_port}, aborting entry setup." + ) + await bridge.stop() + return False + + if not allow_events: + HOMESEER_PLATFORMS.remove("scene") + + hass.data[DOMAIN] = bridge + + for platform in HOMESEER_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + hass.bus.async_listen_once("homeassistant_stop", bridge.stop) + + async def control_device_by_value(call): + ref = call.data[ATTR_REF] + value = call.data[ATTR_VALUE] + + await bridge.api.control_device_by_value(ref, value) + + hass.services.async_register( + DOMAIN, + SERVICE_CONTROL_DEVICE_BY_VALUE, + control_device_by_value, + schema=SERVICE_CONTROL_DEVICE_BY_VALUE_SCHEMA, + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload the config entry and platforms.""" + bridge = hass.data.pop(DOMAIN) + await bridge.stop() + + tasks = [] + for platform in HOMESEER_PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + return all(await asyncio.gather(*tasks)) diff --git a/custom_components/homeseer/binary_sensor.py b/custom_components/homeseer/binary_sensor.py new file mode 100644 index 0000000..b9bcc1e --- /dev/null +++ b/custom_components/homeseer/binary_sensor.py @@ -0,0 +1,35 @@ +"""Support for HomeSeer binary-sensor-type devices.""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer binary-sensor-type devices.""" + binary_sensor_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["binary_sensor"]: + entity = HomeSeerBinarySensor(device, bridge) + binary_sensor_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer binary-sensor-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if binary_sensor_entities: + async_add_entities(binary_sensor_entities) + + +class HomeSeerBinarySensor(HomeSeerEntity, BinarySensorEntity): + """Representation of a HomeSeer binary-sensor-type device.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.value > 0 diff --git a/custom_components/homeseer/config_flow.py b/custom_components/homeseer/config_flow.py new file mode 100644 index 0000000..09c846e --- /dev/null +++ b/custom_components/homeseer/config_flow.py @@ -0,0 +1,269 @@ +"""Config flow support for the HomeSeer integration.""" + +import asyncio +import logging +from libhomeseer import ( + HomeSeer, + HomeSeerSwitchableDevice, + DEFAULT_USERNAME, + DEFAULT_PASSWORD, + DEFAULT_HTTP_PORT, + DEFAULT_ASCII_PORT, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_ALLOW_EVENTS, + CONF_ALLOWED_EVENT_GROUPS, + CONF_ALLOWED_INTERFACES, + CONF_ASCII_PORT, + CONF_FORCED_COVERS, + CONF_HTTP_PORT, + CONF_NAME_TEMPLATE, + CONF_NAMESPACE, + DEFAULT_ALLOW_EVENTS, + DEFAULT_NAME_TEMPLATE, + DEFAULT_NAMESPACE, + DEFAULT_INTERFACE_NAME, + DOMAIN, +) +from .homeseer_quirks import HOMESEER_QUIRKS + +USER_STEP_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Required(CONF_HTTP_PORT, default=DEFAULT_HTTP_PORT): cv.positive_int, + vol.Required(CONF_ASCII_PORT, default=DEFAULT_ASCII_PORT): cv.positive_int, + } +) + +CONFIG_STEP_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAMESPACE, default=DEFAULT_NAMESPACE): cv.string, + vol.Required(CONF_NAME_TEMPLATE, default=DEFAULT_NAME_TEMPLATE): cv.string, + vol.Required(CONF_ALLOW_EVENTS, default=DEFAULT_ALLOW_EVENTS): cv.boolean, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a HomeSeer config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + self._host = None + self._username = None + self._password = None + self._http_port = None + self._ascii_port = None + self._namespace = None + self._name_template = None + self._allow_events = None + self._all_devices = [] + self._all_events = [] + self._interfaces = [] + self._switches = [] + self._event_groups = [] + self._allowed_interfaces = [] + self._group_flag = True + self._allowed_groups = [] + self._cover_flag = True + self._forced_covers = [] + + async def async_step_user(self, user_input=None): + """Basic data for the HomeSeer instance is provided by the user.""" + errors = {} + if self._async_current_entries(): + # Config entry already exists, only one allowed for now. + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + bridge = HomeSeer( + user_input[CONF_HOST], + async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_HTTP_PORT], + user_input[CONF_ASCII_PORT], + ) + + try: + await asyncio.wait_for(bridge.initialize(), 20) + except asyncio.TimeoutError: + _LOGGER.error( + f"Could not connect to HomeSeer at {user_input[CONF_HOST]}" + ) + + if bridge.devices or bridge.events: + self._host = user_input[CONF_HOST] + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + self._http_port = user_input[CONF_HTTP_PORT] + self._ascii_port = user_input[CONF_ASCII_PORT] + + for device in bridge.devices.values(): + self._all_devices.append(device) + for event in bridge.events: + self._all_events.append(event) + + return await self.async_step_config() + errors["base"] = "initialize_failed" + + return self.async_show_form( + step_id="user", data_schema=USER_STEP_SCHEMA, errors=errors + ) + + async def async_step_config(self, user_input=None): + """Initial configuration options are provided by the user.""" + errors = {} + if user_input is not None: + self._namespace = user_input[CONF_NAMESPACE] + self._allow_events = user_input[CONF_ALLOW_EVENTS] + + try: + cv.template(str(user_input[CONF_NAME_TEMPLATE])) + self._name_template = user_input[CONF_NAME_TEMPLATE] + return await self.async_step_interfaces() + + except (vol.Invalid, TemplateError): + errors["base"] = "template_failed" + + return self.async_show_form( + step_id="config", data_schema=CONFIG_STEP_SCHEMA, errors=errors + ) + + async def async_step_interfaces(self, user_input=None): + """Allowed HomeSeer device interfaces are selected by the user.""" + if user_input is not None: + self._allowed_interfaces = user_input[CONF_ALLOWED_INTERFACES] + return await self.handle_next_step() + + for device in self._all_devices: + iname = ( + device.interface_name + if device.interface_name is not None + else DEFAULT_INTERFACE_NAME + ) + if iname not in self._interfaces: + self._interfaces.append(iname) + + self._interfaces.sort() + + return self.async_show_form( + step_id="interfaces", + data_schema=vol.Schema( + { + vol.Required( + CONF_ALLOWED_INTERFACES, default=self._interfaces + ): cv.multi_select(self._interfaces), + } + ), + ) + + async def async_step_groups(self, user_input=None): + """Allowed HomeSeer event groups are selected by the user.""" + if user_input is not None: + if user_input.get(CONF_ALLOWED_EVENT_GROUPS) is not None: + self._allowed_groups = user_input[CONF_ALLOWED_EVENT_GROUPS] + + return await self.handle_next_step() + + for event in self._all_events: + if event.group not in self._event_groups: + self._event_groups.append(event.group) + + self._event_groups.sort() + + return self.async_show_form( + step_id="groups", + data_schema=vol.Schema( + { + vol.Optional(CONF_ALLOWED_EVENT_GROUPS): cv.multi_select( + self._event_groups + ) + } + ), + ) + + async def async_step_covers(self, user_input=None): + """Devices to force as covers are selected by the user.""" + if user_input is not None: + if user_input.get(CONF_FORCED_COVERS) is not None: + self._forced_covers = user_input[CONF_FORCED_COVERS] + + return await self.handle_next_step() + + default_covers = [] + + for device in self._all_devices: + iname = ( + device.interface_name + if device.interface_name is not None + else "HomeSeer" + ) + if iname in self._allowed_interfaces and isinstance( + device, HomeSeerSwitchableDevice + ): + self._switches.append(device.ref) + try: + if HOMESEER_QUIRKS[device.device_type_string] == "cover": + default_covers.append(device.ref) + except KeyError: + pass + + self._switches.sort() + + return self.async_show_form( + step_id="covers", + data_schema=vol.Schema( + { + vol.Optional( + CONF_FORCED_COVERS, default=default_covers + ): cv.multi_select(self._switches) + } + ), + ) + + async def handle_next_step(self): + """Determine which step to show to the user next based on available data.""" + if self._group_flag and self._allow_events: + self._group_flag = False + return await self.async_step_groups() + elif self._cover_flag: + self._cover_flag = False + return await self.async_step_covers() + return self.finalize_config_entry_flow() + + def finalize_config_entry_flow(self): + return self.async_create_entry( + title=f"{self._host}:{self._http_port}", + data={ + CONF_HOST: self._host, + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_HTTP_PORT: self._http_port, + CONF_ASCII_PORT: self._ascii_port, + CONF_NAMESPACE: self._namespace, + CONF_NAME_TEMPLATE: self._name_template, + CONF_ALLOW_EVENTS: self._allow_events, + CONF_FORCED_COVERS: self._forced_covers, + CONF_ALLOWED_EVENT_GROUPS: self._allowed_groups, + CONF_ALLOWED_INTERFACES: self._allowed_interfaces, + }, + ) diff --git a/const.py b/custom_components/homeseer/const.py similarity index 64% rename from const.py rename to custom_components/homeseer/const.py index 8e43084..2ba6f57 100644 --- a/const.py +++ b/custom_components/homeseer/const.py @@ -1,18 +1,15 @@ -""" -Constants for the HomeSeer component. -""" - -import logging - -_LOGGER = logging.getLogger(__name__) +"""Constants for the HomeSeer integration.""" DOMAIN = "homeseer" ATTR_REF = "ref" +ATTR_LOCATION = "location" +ATTR_LOCATION2 = "location2" +ATTR_NAME = "name" ATTR_VALUE = "value" - -ATTR_REF = "ref" -ATTR_VALUE = "value" +ATTR_STATUS = "status" +ATTR_DEVICE_TYPE_STRING = "device_type_string" +ATTR_LAST_CHANGE = "last_change" CONF_HTTP_PORT = "http_port" CONF_ASCII_PORT = "ascii_port" @@ -21,15 +18,14 @@ CONF_NAME_TEMPLATE = "name_template" CONF_ALLOWED_EVENT_GROUPS = "allowed_event_groups" CONF_FORCED_COVERS = "forced_covers" +CONF_ALLOWED_INTERFACES = "allowed_interfaces" -DEFAULT_HTTP_PORT = 80 -DEFAULT_PASSWORD = "default" -DEFAULT_USERNAME = "default" -DEFAULT_ASCII_PORT = 11000 DEFAULT_NAME_TEMPLATE = "{{ device.location2 }} {{ device.location }} {{ device.name }}" +DEFAULT_NAMESPACE = "homeseer" DEFAULT_ALLOW_EVENTS = True DEFAULT_ALLOWED_EVENT_GROUPS = [] DEFAULT_FORCED_COVERS = [] +DEFAULT_INTERFACE_NAME = "HomeSeer" HOMESEER_PLATFORMS = [ "binary_sensor", diff --git a/custom_components/homeseer/cover.py b/custom_components/homeseer/cover.py new file mode 100644 index 0000000..cc962c6 --- /dev/null +++ b/custom_components/homeseer/cover.py @@ -0,0 +1,109 @@ +"""Support for HomeSeer cover-type devices.""" + +import logging + +from homeassistant.components.cover import ( + CoverEntity, + ATTR_POSITION, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_GARAGE, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, +) + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer cover-type devices.""" + cover_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["cover"]: + if hasattr(device, "dim"): + """Device is a blind.""" + entity = HomeSeerBlind(device, bridge) + cover_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer blind-type device: {entity.name} ({entity.device_state_attributes})" + ) + else: + """Device is a garage-door opener.""" + entity = HomeSeerGarageDoor(device, bridge) + cover_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer garage-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if cover_entities: + async_add_entities(cover_entities) + + +class HomeSeerCover(HomeSeerEntity, CoverEntity): + """Base representation for a HomeSeer cover entity.""" + + async def async_open_cover(self, **kwargs): + await self._device.on() + + async def async_close_cover(self, **kwargs): + await self._device.off() + + +class HomeSeerGarageDoor(HomeSeerCover): + """Representation of a garage door opener device.""" + + @property + def supported_features(self): + """Return the features supported by the device.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def device_class(self): + """Return the device class for the device.""" + return DEVICE_CLASS_GARAGE + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._device.status == "Opening" + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._device.status == "Closing" + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self._device.status == "Closed" + + +class HomeSeerBlind(HomeSeerCover): + """Representation of a window-covering device.""" + + @property + def supported_features(self): + """Return the features supported by the device.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + @property + def device_class(self): + """Return the device class for the device.""" + return DEVICE_CLASS_BLIND + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return int(self._device.dim_percent * 100) + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return not self._device.is_on + + async def async_set_cover_position(self, **kwargs): + await self._device.dim(kwargs.get(ATTR_POSITION, 0)) diff --git a/custom_components/homeseer/homeseer.py b/custom_components/homeseer/homeseer.py new file mode 100644 index 0000000..972f667 --- /dev/null +++ b/custom_components/homeseer/homeseer.py @@ -0,0 +1,314 @@ +"""Provides HomeSeer specific implementations for bridges, entities, and remotes.""" + +from libhomeseer import ( + HomeSeer, + HomeSeerStatusDevice, + HomeSeerSwitchableDevice, + HomeSeerLockableDevice, + HomeSeerDimmableDevice, + get_datetime_from_last_change, + RELATIONSHIP_CHILD, + RELATIONSHIP_ROOT, +) +import logging +from typing import Optional, Union + +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import EventOrigin, HomeAssistant +from homeassistant.helpers import aiohttp_client, template +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_REF, + ATTR_LOCATION, + ATTR_LOCATION2, + ATTR_NAME, + ATTR_VALUE, + ATTR_STATUS, + ATTR_DEVICE_TYPE_STRING, + ATTR_LAST_CHANGE, + DEFAULT_INTERFACE_NAME, + DOMAIN, +) +from .homeseer_quirks import HOMESEER_QUIRKS + +_LOGGER = logging.getLogger(__name__) + +DEVICES_MODEL = { + "binary_sensor": [], + "cover": [], + "light": [], + "lock": [], + "remote": [], + "sensor": [], + "scene": [], + "switch": [], +} + + +class HomeSeerBridge: + """Manages a single connection between HomeSeer and Home Assistant.""" + + def __init__( + self, + hass: HomeAssistant, + host: str, + username: str, + password: str, + http_port: int, + ascii_port: int, + namespace: str, + name_template: template, + allowed_event_groups: list, + forced_covers: list, + allowed_interfaces: list, + ): + self._hass = hass + self._session = aiohttp_client.async_get_clientsession(self._hass) + self.api = HomeSeer( + host, + self._session, + username=username, + password=password, + http_port=http_port, + ascii_port=ascii_port, + ) + self._namespace = namespace + self._name_template = name_template + self._allowed_event_groups = allowed_event_groups + self._forced_covers = forced_covers + self._allowed_interfaces = allowed_interfaces + self._devices = DEVICES_MODEL + self.remotes = [] + + @property + def devices(self) -> dict: + return self._devices + + @property + def namespace(self) -> str: + return self._namespace + + @property + def name_template(self) -> template.Template: + return self._name_template + + @property + def allowed_event_groups(self) -> list: + return self._allowed_event_groups + + @property + def forced_covers(self) -> list: + return self._forced_covers + + @property + def allowed_interfaces(self) -> list: + return self._allowed_interfaces + + async def setup(self) -> bool: + """Initialize the HomeSeer API and sort devices.""" + await self.api.initialize() + if not self.api.devices and not self.api.events: + return False + + for device in self.api.devices.values(): + platform = self._get_ha_platform_for_homeseer_device(device) + if platform is not None: + self._devices[platform].append(device) + + for event in self.api.events: + if ( + self.allowed_event_groups + and event.group not in self.allowed_event_groups + ): + continue + self._devices["scene"].append(event) + + for device in self.devices["remote"]: + self.remotes.append(HomeSeerRemote(self._hass, device)) + _LOGGER.info( + f"Added HomeSeer remote-type device: " + f"{device.location2} {device.location} {device.name } ({device.ref})" + ) + + return True + + async def start(self) -> None: + """Start listening to HomeSeer for device updates.""" + await self.api.start_listener() + + async def stop(self, *args) -> None: + """Stop listening to HomeSeer for device updates.""" + await self.api.stop_listener() + + def _get_ha_platform_for_homeseer_device( + self, + device: Union[ + HomeSeerStatusDevice, + HomeSeerSwitchableDevice, + HomeSeerDimmableDevice, + HomeSeerLockableDevice, + ], + ) -> Optional[str]: + """ + Return the correct platform for the given device. + This method ensures that a HomeSeer device will only ever be represented in one platform, + and filters out devices as required. + """ + # Filter out HomeSeer "Root" devices; they will be used for device info at the entity level only. + if device.relationship == RELATIONSHIP_ROOT: + _LOGGER.debug( + f"Device ref {device.ref} is a root device, " + f"not creating an entity for this device" + ) + return None + + # Filter out devices from interfaces not selected during the Config Flow. + iname = ( + device.interface_name + if device.interface_name is not None + else DEFAULT_INTERFACE_NAME + ) + if iname not in self.allowed_interfaces: + _LOGGER.debug( + f"Device ref {device.ref} is from disabled interface {iname}, " + f"not creating an entity for this device" + ) + return None + + # Force certain devices selected during the Config Flow to be covers. + if device.ref in self.forced_covers: + _LOGGER.debug( + f"Device ref {device.ref} is forced as a cover, " + f"creating a cover entity for this device" + ) + return "cover" + + # Some devices should be represented as a Home Assistant platform other than what the HS API data suggests + # These devices can be "forced" into other platforms by adding their "device_type_string" to the + # homeseer_quirks.py file. + try: + return HOMESEER_QUIRKS[device.device_type_string] + except KeyError: + _LOGGER.debug( + f"No platform quirk found for device type string " + f"{device.device_type_string}, " + f"automatically assigning a platform for device ref {device.ref}" + ) + + # Return the platform for the device based on the libhomeseer device type + if type(device) == HomeSeerSwitchableDevice: + return "switch" + elif type(device) == HomeSeerDimmableDevice: + return "light" + elif type(device) == HomeSeerLockableDevice: + return "lock" + elif type(device) == HomeSeerStatusDevice: + return "sensor" + + _LOGGER.debug( + f"No valid platform detected for device ref {device.ref} (type: {type(device)})" + ) + return None + + +class HomeSeerEntity(Entity): + """Base representation for all HomeSeer entities.""" + + def __init__( + self, + device: Union[ + HomeSeerStatusDevice, + HomeSeerSwitchableDevice, + HomeSeerDimmableDevice, + HomeSeerLockableDevice, + ], + bridge: HomeSeerBridge, + ): + self._device = device + self._bridge = bridge + + @property + def available(self) -> bool: + """Return True if the HomeSeer connection is listening.""" + return self._bridge.api.available + + @property + def device_state_attributes(self) -> dict: + """Return a dictionary of state attributes.""" + dt = get_datetime_from_last_change(self._device.last_change) + attr = { + ATTR_REF: self._device.ref, + ATTR_LOCATION2: self._device.location2, + ATTR_LOCATION: self._device.location, + ATTR_NAME: self._device.name, + ATTR_DEVICE_TYPE_STRING: self._device.device_type_string, + ATTR_VALUE: self._device.value, + ATTR_STATUS: self._device.status, + ATTR_LAST_CHANGE: ( + dt.astimezone().isoformat("T", "seconds") if dt is not None else None + ), + } + return attr + + @property + def unique_id(self) -> str: + """Return a unique ID for the device.""" + return f"{self._bridge.namespace}-{self._device.ref}" + + @property + def name(self) -> str: + """Return the name of the device rendered from the user-supplied template.""" + return self._bridge.name_template.async_render(device=self._device).strip() + + @property + def device_info(self) -> dict: + """Return device info for the parent device.""" + parent_ref = self._device.ref + if self._device.relationship == RELATIONSHIP_CHILD: + parent_ref = self._device.associated_devices[0] + + parent = self._bridge.api.devices[parent_ref] + + return { + "identifiers": {(self._bridge.namespace, parent.ref)}, + "name": f"{parent.location2} {parent.location} {parent.name}", + "model": parent.device_type_string, + "manufacturer": parent.interface_name, + "via_device": (DOMAIN, self._bridge.namespace), + } + + @property + def should_poll(self) -> bool: + """No polling necessary for HomeSeer entities.""" + return False + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + self._device.register_update_callback(self.async_schedule_update_ha_state) + + +class HomeSeerRemote: + """Link remote-type devices that should fire events rather than create entities to Home Assistant.""" + + def __init__( + self, + hass: HomeAssistant, + device: Union[ + HomeSeerStatusDevice, + HomeSeerSwitchableDevice, + HomeSeerDimmableDevice, + HomeSeerLockableDevice, + ], + ) -> None: + self._hass = hass + self._device = device + self._device.register_update_callback( + self.update_callback, suppress_on_connection=True + ) + self._event = f"homeseer_{CONF_EVENT}" + + def update_callback(self): + """Fire the event.""" + data = {CONF_ID: self._device.ref, CONF_EVENT: self._device.value} + self._hass.bus.async_fire(self._event, data, EventOrigin.remote) diff --git a/custom_components/homeseer/homeseer_quirks.py b/custom_components/homeseer/homeseer_quirks.py new file mode 100644 index 0000000..55f1ed1 --- /dev/null +++ b/custom_components/homeseer/homeseer_quirks.py @@ -0,0 +1,17 @@ +""" +Support for HomeSeer quirks. +Some devices should be represented as a Home Assistant platform other than what the HS API data suggests. +These devices can be "forced" into other platforms by adding their "device_type_string" to the dict below. +""" + +from libhomeseer import ( + DEVICE_ZWAVE_BARRIER_OPERATOR, + DEVICE_ZWAVE_CENTRAL_SCENE, + DEVICE_ZWAVE_SENSOR_BINARY, +) + +HOMESEER_QUIRKS = { + DEVICE_ZWAVE_BARRIER_OPERATOR: "cover", + DEVICE_ZWAVE_CENTRAL_SCENE: "remote", + DEVICE_ZWAVE_SENSOR_BINARY: "binary_sensor", +} diff --git a/custom_components/homeseer/light.py b/custom_components/homeseer/light.py new file mode 100644 index 0000000..0f575ff --- /dev/null +++ b/custom_components/homeseer/light.py @@ -0,0 +1,62 @@ +"""Support for HomeSeer light-type devices.""" + +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer light-type devices.""" + light_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["light"]: + entity = HomeSeerLight(device, bridge) + light_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer light-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if light_entities: + async_add_entities(light_entities) + + +class HomeSeerLight(HomeSeerEntity, LightEntity): + """Representation of a HomeSeer light-type device.""" + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def brightness(self): + """Return the brightness of the light.""" + bri = self._device.dim_percent * 255 + if bri > 255: + return 255 + return bri + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + percent = int(brightness / 255 * 100) + await self._device.dim(percent) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._device.off() diff --git a/custom_components/homeseer/lock.py b/custom_components/homeseer/lock.py new file mode 100644 index 0000000..2bbe854 --- /dev/null +++ b/custom_components/homeseer/lock.py @@ -0,0 +1,41 @@ +"""Support for HomeSeer lock-type devices.""" + +import logging + +from homeassistant.components.lock import LockEntity + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer lock-type devices.""" + lock_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["lock"]: + entity = HomeSeerLock(device, bridge) + lock_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer lock-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if lock_entities: + async_add_entities(lock_entities) + + +class HomeSeerLock(HomeSeerEntity, LockEntity): + """Representation of a HomeSeer lock device.""" + + @property + def is_locked(self): + """Return true if device is locked.""" + return self._device.is_locked + + async def async_lock(self, **kwargs): + await self._device.lock() + + async def async_unlock(self, **kwargs): + await self._device.unlock() diff --git a/custom_components/homeseer/manifest.json b/custom_components/homeseer/manifest.json new file mode 100644 index 0000000..92ff4f6 --- /dev/null +++ b/custom_components/homeseer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "config_flow": true, + "documentation": "https://github.com/marthoc/homeseer", + "issue_tracker": "https://github.com/marthoc/homeseer/issues", + "codeowners": ["@marthoc"], + "requirements": ["libhomeseer==1.2.2"], + "version": "1.0.0" +} diff --git a/custom_components/homeseer/scene.py b/custom_components/homeseer/scene.py new file mode 100644 index 0000000..3d11af7 --- /dev/null +++ b/custom_components/homeseer/scene.py @@ -0,0 +1,41 @@ +"""Support for HomeSeer Events.""" + +import logging +from homeassistant.components.scene import Scene + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer events as Home Assistant scenes.""" + scenes = [] + bridge = hass.data[DOMAIN] + + for event in bridge.devices["scene"]: + entity = HomeSeerScene(event) + scenes.append(entity) + _LOGGER.info(f"Added HomeSeer event: {entity.name}") + + if scenes: + async_add_entities(scenes) + + +class HomeSeerScene(Scene): + """Representation of a HomeSeer event.""" + + def __init__(self, event): + self._event = event + self._group = self._event.group + self._name = self._event.name + self._scene_name = f"{self._group} {self._name}" + + @property + def name(self): + """Return the name of the scene.""" + return self._scene_name + + async def async_activate(self): + """Activate the scene.""" + await self._event.run() diff --git a/custom_components/homeseer/sensor.py b/custom_components/homeseer/sensor.py new file mode 100644 index 0000000..a898319 --- /dev/null +++ b/custom_components/homeseer/sensor.py @@ -0,0 +1,223 @@ +"""Support for HomeSeer sensor-type devices.""" + +import logging +from libhomeseer import ( + DEVICE_ZWAVE_BATTERY, + DEVICE_ZWAVE_DOOR_LOCK_LOGGING, + DEVICE_ZWAVE_ELECTRIC_METER, + DEVICE_ZWAVE_FAN_STATE, + DEVICE_ZWAVE_LUMINANCE, + DEVICE_ZWAVE_OPERATING_STATE, + DEVICE_ZWAVE_RELATIVE_HUMIDITY, + DEVICE_ZWAVE_SENSOR_MULTILEVEL, + HS_UNIT_A, + HS_UNIT_AMPERES, + HS_UNIT_CELSIUS, + HS_UNIT_FAHRENHEIT, + HS_UNIT_KW, + HS_UNIT_KWH, + HS_UNIT_LUX, + HS_UNIT_PERCENTAGE, + HS_UNIT_V, + HS_UNIT_VOLTS, + HS_UNIT_W, + HS_UNIT_WATTS, + get_uom_from_status, +) + +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRICAL_CURRENT_AMPERE, + ENERGY_KILO_WATT_HOUR, + LIGHT_LUX, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + PERCENTAGE, + POWER_KILO_WATT, + POWER_WATT, + VOLT, +) + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + +GENERIC_VALUE_SENSOR_TYPES = [ + DEVICE_ZWAVE_ELECTRIC_METER, + DEVICE_ZWAVE_LUMINANCE, + DEVICE_ZWAVE_SENSOR_MULTILEVEL, +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer sensor-type devices.""" + sensor_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["sensor"]: + entity = get_sensor_entity(device, bridge) + sensor_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer sensor-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if sensor_entities: + async_add_entities(sensor_entities) + + +class HomeSeerStatusSensor(HomeSeerEntity): + """Base representation of a HomeSeer sensor-type device that reports text values (status).""" + + @property + def state(self): + return self._device.status + + +class HomeSeerValueSensor(HomeSeerEntity): + """Base representation of a HomeSeer sensor-type device that reports numeric values.""" + + @property + def state(self): + return self._device.value + + @property + def unit_of_measurement(self): + """Return the unit of measurement parsed from the device's status.""" + unit = get_uom_from_status(self._device.status) + if unit == HS_UNIT_LUX: + return LIGHT_LUX + elif unit == HS_UNIT_CELSIUS: + return TEMP_CELSIUS + elif unit == HS_UNIT_FAHRENHEIT: + return TEMP_FAHRENHEIT + elif unit == HS_UNIT_PERCENTAGE: + return PERCENTAGE + elif unit == HS_UNIT_A or unit == HS_UNIT_AMPERES: + return ELECTRICAL_CURRENT_AMPERE + elif unit == HS_UNIT_KW: + return POWER_KILO_WATT + elif unit == HS_UNIT_KWH: + return ENERGY_KILO_WATT_HOUR + elif unit == HS_UNIT_V or unit == HS_UNIT_VOLTS: + return VOLT + elif unit == HS_UNIT_W or unit == HS_UNIT_WATTS: + return POWER_WATT + return None + + @property + def device_class(self): + """Return the device class of the device based on the device's unit of measure.""" + unit = get_uom_from_status(self._device.status) + if unit == HS_UNIT_LUX: + return DEVICE_CLASS_ILLUMINANCE + elif unit == HS_UNIT_CELSIUS or unit == HS_UNIT_FAHRENHEIT: + return DEVICE_CLASS_TEMPERATURE + elif unit == HS_UNIT_A or unit == HS_UNIT_AMPERES: + return DEVICE_CLASS_CURRENT + elif unit == HS_UNIT_KW: + return DEVICE_CLASS_POWER + elif unit == HS_UNIT_KWH: + return DEVICE_CLASS_ENERGY + elif unit == HS_UNIT_V or unit == HS_UNIT_VOLTS: + return DEVICE_CLASS_VOLTAGE + elif unit == HS_UNIT_W or unit == HS_UNIT_WATTS: + return DEVICE_CLASS_POWER + return None + + +class HomeSeerBatterySensor(HomeSeerValueSensor): + """Representation of a HomeSeer device that reports battery level.""" + + @property + def device_class(self): + return DEVICE_CLASS_BATTERY + + @property + def icon(self): + if self.state == 100: + return "mdi:battery" + elif self.state > 89: + return "mdi:battery-90" + elif self.state > 79: + return "mdi:battery-80" + elif self.state > 69: + return "mdi:battery-70" + elif self.state > 59: + return "mdi:battery-60" + elif self.state > 49: + return "mdi:battery-50" + elif self.state > 39: + return "mdi:battery-40" + elif self.state > 29: + return "mdi:battery-30" + elif self.state > 19: + return "mdi:battery-20" + elif self.state > 9: + return "mdi:battery-10" + return None + + +class HomeSeerHumiditySensor(HomeSeerValueSensor): + """Representation of a HomeSeer humidity sensor device.""" + + @property + def device_class(self): + return DEVICE_CLASS_HUMIDITY + + +class HomeSeerFanStateSensor(HomeSeerStatusSensor): + """Representation of a HomeSeer HVAC fan state sensor device.""" + + @property + def icon(self): + if self._device.value == 0: + return "mdi:fan-off" + return "mdi:fan" + + +class HomeSeerOperatingStateSensor(HomeSeerStatusSensor): + """Representation of a HomeSeer HVAC operating state sensor device.""" + + @property + def icon(self): + if self.state == "Idle": + return "mdi:fan-off" + elif self.state == "Heating": + return "mdi:flame" + elif self.state == "Cooling": + return "mdi:snowflake" + return "mdi:fan" + + +class HomeSeerDoorLockLoggingSensor(HomeSeerStatusSensor): + """Representation of a door-lock-logging sensor.""" + + @property + def icon(self) -> str: + """Return an appropriate lock icon.""" + return "mdi:lock-clock" + + +def get_sensor_entity(device, connection): + """Return the proper sensor object based on device type.""" + if device.device_type_string == DEVICE_ZWAVE_BATTERY: + return HomeSeerBatterySensor(device, connection) + elif device.device_type_string == DEVICE_ZWAVE_RELATIVE_HUMIDITY: + return HomeSeerHumiditySensor(device, connection) + elif device.device_type_string == DEVICE_ZWAVE_FAN_STATE: + return HomeSeerFanStateSensor(device, connection) + elif device.device_type_string == DEVICE_ZWAVE_OPERATING_STATE: + return HomeSeerOperatingStateSensor(device, connection) + elif device.device_type_string == DEVICE_ZWAVE_DOOR_LOCK_LOGGING: + return HomeSeerDoorLockLoggingSensor(device, connection) + elif device.device_type_string in GENERIC_VALUE_SENSOR_TYPES: + return HomeSeerValueSensor(device, connection) + return HomeSeerStatusSensor(device, connection) diff --git a/services.yaml b/custom_components/homeseer/services.yaml similarity index 69% rename from services.yaml rename to custom_components/homeseer/services.yaml index 37299d9..9365954 100644 --- a/services.yaml +++ b/custom_components/homeseer/services.yaml @@ -6,5 +6,5 @@ control_device_by_value: description: Device ref of the HomeSeer device to control (integer). example: 145 value: - description: Value to set the HomeSeer device to. - example: 255 or "255" or 255.0 + description: New value for the HomeSeer device ref (integer). + example: 255 diff --git a/custom_components/homeseer/strings.json b/custom_components/homeseer/strings.json new file mode 100644 index 0000000..ca00e34 --- /dev/null +++ b/custom_components/homeseer/strings.json @@ -0,0 +1,49 @@ +{ + "title": "HomeSeer", + "config": { + "step": { + "user": { + "title": "HomeSeer", + "description": "See https://github.com/marthoc/homeseer for more information on configuring this integration.", + "data": { + "host": "IP Address", + "username": "Username", + "password": "Password", + "http_port": "HTTP Port", + "ascii_port": "ASCII Port" + } + }, + "config": { + "title": "HomeSeer Initial Configuration Options", + "description": "Namespace is a unique string identifying this HomeSeer instance. Name Template defines how new entities are initially named in Home Assistant.", + "data": { + "namespace": "Namespace of this HomeSeer instance", + "name_template": "Entity name template (Jinja2)", + "allow_events": "Create Scenes from HomeSeer Events?" + } + }, + "interfaces": { + "title": "HomeSeer Device Interfaces to allow in Home Assistant", + "description": "Select the types of technology interfaces present in HomeSeer to allow in Home Assistant. The type 'HomeSeer' represents devices native to HomeSeer such as virtual devices. (Note: Z-Wave is best-supported, but most devices from other interfaces should 'just work'.)", + "data": {"allowed_interfaces": "Select HomeSeer Device Interfaces"} + }, + "covers": { + "title": "HomeSeer Devices to represent as Covers", + "description": "Some blinds, window coverings, or garage doors are defined in HomeSeer the same way as lights or switches. Select which of these device refs should actually be represented as Covers in Home Assistant.", + "data": {"forced_covers": "Select HomeSeer devices"} + }, + "groups": { + "title": "Filter HomeSeer Event Groups", + "description": "Select which HomeSeer event groups will be allowed in Home Assistant. Selecting any groups here will allow ONLY those groups; selecting no groups here will allow ALL event groups.", + "data": {"allowed_event_groups": "Select HomeSeer event groups"} + } + }, + "error": { + "initialize_failed": "Cannot connect to HomeSeer, or no devices found that are supported by Home Assistant", + "template_failed": "The provided entity name template is not a valid Jinja2 template" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/custom_components/homeseer/switch.py b/custom_components/homeseer/switch.py new file mode 100644 index 0000000..d2cb29e --- /dev/null +++ b/custom_components/homeseer/switch.py @@ -0,0 +1,41 @@ +"""Support for HomeSeer switch-type devices.""" + +import logging + +from homeassistant.components.switch import SwitchEntity + +from .const import DOMAIN +from .homeseer import HomeSeerEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up HomeSeer switch-type devices.""" + switch_entities = [] + bridge = hass.data[DOMAIN] + + for device in bridge.devices["switch"]: + entity = HomeSeerSwitch(device, bridge) + switch_entities.append(entity) + _LOGGER.info( + f"Added HomeSeer switch-type device: {entity.name} ({entity.device_state_attributes})" + ) + + if switch_entities: + async_add_entities(switch_entities) + + +class HomeSeerSwitch(HomeSeerEntity, SwitchEntity): + """Representation of a HomeSeer switch-type device.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + await self._device.on() + + async def async_turn_off(self, **kwargs): + await self._device.off() diff --git a/custom_components/homeseer/translations/en.json b/custom_components/homeseer/translations/en.json new file mode 100644 index 0000000..ca00e34 --- /dev/null +++ b/custom_components/homeseer/translations/en.json @@ -0,0 +1,49 @@ +{ + "title": "HomeSeer", + "config": { + "step": { + "user": { + "title": "HomeSeer", + "description": "See https://github.com/marthoc/homeseer for more information on configuring this integration.", + "data": { + "host": "IP Address", + "username": "Username", + "password": "Password", + "http_port": "HTTP Port", + "ascii_port": "ASCII Port" + } + }, + "config": { + "title": "HomeSeer Initial Configuration Options", + "description": "Namespace is a unique string identifying this HomeSeer instance. Name Template defines how new entities are initially named in Home Assistant.", + "data": { + "namespace": "Namespace of this HomeSeer instance", + "name_template": "Entity name template (Jinja2)", + "allow_events": "Create Scenes from HomeSeer Events?" + } + }, + "interfaces": { + "title": "HomeSeer Device Interfaces to allow in Home Assistant", + "description": "Select the types of technology interfaces present in HomeSeer to allow in Home Assistant. The type 'HomeSeer' represents devices native to HomeSeer such as virtual devices. (Note: Z-Wave is best-supported, but most devices from other interfaces should 'just work'.)", + "data": {"allowed_interfaces": "Select HomeSeer Device Interfaces"} + }, + "covers": { + "title": "HomeSeer Devices to represent as Covers", + "description": "Some blinds, window coverings, or garage doors are defined in HomeSeer the same way as lights or switches. Select which of these device refs should actually be represented as Covers in Home Assistant.", + "data": {"forced_covers": "Select HomeSeer devices"} + }, + "groups": { + "title": "Filter HomeSeer Event Groups", + "description": "Select which HomeSeer event groups will be allowed in Home Assistant. Selecting any groups here will allow ONLY those groups; selecting no groups here will allow ALL event groups.", + "data": {"allowed_event_groups": "Select HomeSeer event groups"} + } + }, + "error": { + "initialize_failed": "Cannot connect to HomeSeer, or no devices found that are supported by Home Assistant", + "template_failed": "The provided entity name template is not a valid Jinja2 template" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8e98611 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "HomeSeer", + "domains": ["binary_sensor", "cover", "light", "lock", "scene", "sensor", "switch"], + "iot_class": ["Local Push"], + "render_readme": true +} diff --git a/light.py b/light.py deleted file mode 100644 index 88df3b1..0000000 --- a/light.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Support for HomeSeer light-type devices. -""" - -from pyhs3 import HASS_LIGHTS, STATE_LISTENING - -from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, LightEntity - -from .const import _LOGGER, DOMAIN, CONF_FORCED_COVERS - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer light-type devices.""" - light_devices = [] - forced_covers = discovery_info[CONF_FORCED_COVERS] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string in HASS_LIGHTS and int(device.ref) not in forced_covers: - dev = HSLight(device, homeseer) - light_devices.append(dev) - _LOGGER.info(f"Added HomeSeer light-type device: {dev.name}") - - async_add_entities(light_devices) - - -class HSLight(LightEntity): - """Representation of a HomeSeer light-type device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - - @property - def available(self): - """Return whether the device is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_BRIGHTNESS - - @property - def brightness(self): - """Return the brightness of the light.""" - bri = self._device.dim_percent * 255 - if bri > 255: - return 255 - return bri - - @property - def is_on(self): - """Return true if device is on.""" - return self._device.is_on - - async def async_turn_on(self, **kwargs): - """Turn the light on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS, 255) - percent = int(brightness / 255 * 100) - await self._device.dim(percent) - - async def async_turn_off(self, **kwargs): - """Turn the light off.""" - await self._device.off() - - async def async_added_to_hass(self): - """Register value update callback.""" - self._device.register_update_callback(self.async_schedule_update_ha_state) diff --git a/lock.py b/lock.py deleted file mode 100644 index 9f9694c..0000000 --- a/lock.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Support for HomeSeer lock-type devices. -""" - -from pyhs3 import HASS_LOCKS, STATE_LISTENING - -from homeassistant.components.lock import LockEntity - -from .const import _LOGGER, DOMAIN - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer lock-type devices.""" - lock_devices = [] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string in HASS_LOCKS: - dev = HSLock(device, homeseer) - lock_devices.append(dev) - _LOGGER.info(f"Added HomeSeer lock-type device: {dev.name}") - - async_add_entities(lock_devices) - - -class HSLock(LockEntity): - """Representation of a HomeSeer lock device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - - @property - def available(self): - """Return whether the device is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_locked(self): - """Return true if device is locked.""" - return self._device.is_locked - - async def async_lock(self, **kwargs): - await self._device.lock() - - async def async_unlock(self, **kwargs): - await self._device.unlock() - - async def async_added_to_hass(self): - """Register value update callback.""" - self._device.register_update_callback(self.async_schedule_update_ha_state) diff --git a/manifest.json b/manifest.json deleted file mode 100644 index 3209ad7..0000000 --- a/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "homeseer", - "name": "HomeSeer", - "documentation": "https://github.com/marthoc/homeseer", - "dependencies": [], - "codeowners": ["@marthoc"], - "requirements": ["pyhs3==0.14"] -} diff --git a/scene.py b/scene.py deleted file mode 100644 index 5a53923..0000000 --- a/scene.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Support for HomeSeer Events. -""" - -from homeassistant.components.scene import Scene - -from .const import _LOGGER, CONF_ALLOWED_EVENT_GROUPS, DOMAIN - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer events as Home Assistant scenes.""" - scenes = [] - allowed_event_groups = discovery_info[CONF_ALLOWED_EVENT_GROUPS] - homeseer = hass.data[DOMAIN] - - for event in homeseer.events: - if len(allowed_event_groups) > 0 and event.group not in allowed_event_groups: - continue - dev = HSScene(event) - scenes.append(dev) - _LOGGER.info(f"Added HomeSeer event: {dev.name}") - - async_add_entities(scenes) - - -class HSScene(Scene): - """Representation of a HomeSeer event.""" - - def __init__(self, event): - self._event = event - self._group = self._event.group - self._name = self._event.name - self._scene_name = f"{self._group} {self._name}" - - @property - def name(self): - """Return the name of the scene.""" - return self._scene_name - - async def async_activate(self): - """Activate the scene.""" - await self._event.run() diff --git a/sensor.py b/sensor.py deleted file mode 100644 index f5bbea1..0000000 --- a/sensor.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Support for HomeSeer sensor-type devices. -""" - -from pyhs3 import ( - DEVICE_ZWAVE_BATTERY, - DEVICE_ZWAVE_FAN_STATE, - DEVICE_ZWAVE_LUMINANCE, - DEVICE_ZWAVE_OPERATING_STATE, - DEVICE_ZWAVE_RELATIVE_HUMIDITY, - DEVICE_ZWAVE_SENSOR_MULTILEVEL, - HASS_SENSORS, - STATE_LISTENING, - HS_UNIT_CELSIUS, - HS_UNIT_FAHRENHEIT, - HS_UNIT_LUX, - HS_UNIT_PERCENTAGE, - parse_uom -) - -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, - LIGHT_LUX, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - PERCENTAGE, - ) - -from homeassistant.helpers.entity import Entity - -from .const import _LOGGER, DOMAIN - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer sensor-type devices.""" - sensor_devices = [] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string in HASS_SENSORS: - dev = get_sensor_device(device, homeseer) - sensor_devices.append(dev) - _LOGGER.info(f"Added HomeSeer sensor-type device: {dev.name}") - - async_add_entities(sensor_devices) - - -class HSSensor(Entity): - """Base representation of a HomeSeer sensor-type device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - self._uom = None - - @property - def available(self): - """Return whether the device is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def state(self): - """Return the state of the device.""" - return self._device.value - - @property - def should_poll(self): - """No polling needed.""" - return False - - async def async_added_to_hass(self): - """Register value update callback and cache unit of measure.""" - self._device.register_update_callback(self.async_schedule_update_ha_state) - self._uom = await parse_uom(self._device) - - -class HSBattery(HSSensor): - """Representation of a HomeSeer device that reports battery level.""" - - @property - def unit_of_measurement(self): - return PERCENTAGE - - @property - def icon(self): - if self.state == 100: - return "mdi:battery" - elif self.state > 89: - return "mdi:battery-90" - elif self.state > 79: - return "mdi:battery-80" - elif self.state > 69: - return "mdi:battery-70" - elif self.state > 59: - return "mdi:battery-60" - elif self.state > 49: - return "mdi:battery-50" - elif self.state > 39: - return "mdi:battery-40" - elif self.state > 29: - return "mdi:battery-30" - elif self.state > 19: - return "mdi:battery-20" - elif self.state > 9: - return "mdi:battery-10" - return None - - @property - def device_class(self): - return DEVICE_CLASS_BATTERY - - -class HSHumidity(HSSensor): - """Representation of a HomeSeer humidity sensor device.""" - - @property - def unit_of_measurement(self): - return PERCENTAGE - - @property - def device_class(self): - return DEVICE_CLASS_HUMIDITY - - -class HSLuminance(HSSensor): - """Representation of a HomeSeer light level sensor device.""" - - @property - def unit_of_measurement(self): - return PERCENTAGE - - @property - def device_class(self): - return DEVICE_CLASS_ILLUMINANCE - - -class HSFanState(HSSensor): - """Representation of a HomeSeer HVAC fan state sensor device.""" - - @property - def icon(self): - if self.state == 0: - return "mdi:fan-off" - return "mdi:fan" - - @property - def state(self): - """Return the state of the device.""" - if self._device.value == 0: - return "Off" - elif self._device.value == 1: - return "On" - elif self._device.value == 2: - return "On High" - elif self._device.value == 3: - return "On Medium" - elif self._device.value == 4: - return "On Circulation" - elif self._device.value == 5: - return "On Humidity Circulation" - elif self._device.value == 6: - return "On Right-Left Circulation" - elif self._device.value == 7: - return "On Up-Down Circulation" - elif self._device.value == 8: - return "On Quiet Circulation" - return None - - -class HSOperatingState(HSSensor): - """Representation of a HomeSeer HVAC operating state sensor device.""" - - @property - def icon(self): - if self.state == "Idle": - return "mdi:fan-off" - elif self.state == "Heating": - return "mdi:flame" - elif self.state == "Cooling": - return "mdi:snowflake" - return "mdi:fan" - - @property - def state(self): - """Return the state of the device.""" - if self._device.value == 0: - return "Idle" - elif self._device.value == 1: - return "Heating" - elif self._device.value == 2: - return "Cooling" - elif self._device.value == 3: - return "Fan Only" - elif self._device.value == 4: - return "Pending Heat" - elif self._device.value == 5: - return "Pending Cool" - elif self._device.value == 6: - return "Vent-Economizer" - return None - - -class HSSensorMultilevel(HSSensor): - """Representation of a HomeSeer multi-level sensor.""" - - @property - def device_class(self): - if self._uom == HS_UNIT_LUX: - return DEVICE_CLASS_ILLUMINANCE - if self._uom == HS_UNIT_CELSIUS: - return DEVICE_CLASS_TEMPERATURE - if self._uom == HS_UNIT_FAHRENHEIT: - return DEVICE_CLASS_TEMPERATURE - return None - - @property - def unit_of_measurement(self): - if self._uom == HS_UNIT_LUX: - return LIGHT_LUX - if self._uom == HS_UNIT_CELSIUS: - return TEMP_CELSIUS - if self._uom == HS_UNIT_FAHRENHEIT: - return TEMP_FAHRENHEIT - if self._uom == HS_UNIT_PERCENTAGE: - return PERCENTAGE - return None - - -def get_sensor_device(device, homeseer): - """Return the proper sensor object based on device type.""" - if device.device_type_string == DEVICE_ZWAVE_BATTERY: - return HSBattery(device, homeseer) - elif device.device_type_string == DEVICE_ZWAVE_RELATIVE_HUMIDITY: - return HSHumidity(device, homeseer) - elif device.device_type_string == DEVICE_ZWAVE_LUMINANCE: - return HSLuminance(device, homeseer) - elif device.device_type_string == DEVICE_ZWAVE_FAN_STATE: - return HSFanState(device, homeseer) - elif device.device_type_string == DEVICE_ZWAVE_OPERATING_STATE: - return HSOperatingState(device, homeseer) - elif device.device_type_string == DEVICE_ZWAVE_SENSOR_MULTILEVEL: - return HSSensorMultilevel(device, homeseer) - return HSSensor(device, homeseer) diff --git a/switch.py b/switch.py deleted file mode 100644 index 88dbfc1..0000000 --- a/switch.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Support for HomeSeer switch-type devices. -""" - -from pyhs3 import HASS_SWITCHES, STATE_LISTENING - -from homeassistant.components.switch import SwitchEntity - -from .const import _LOGGER, DOMAIN - -DEPENDENCIES = ["homeseer"] - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up HomeSeer switch-type devices.""" - switch_devices = [] - homeseer = hass.data[DOMAIN] - - for device in homeseer.devices: - if device.device_type_string in HASS_SWITCHES: - dev = HSSwitch(device, homeseer) - switch_devices.append(dev) - _LOGGER.info(f"Added HomeSeer switch-type device: {dev.name}") - - async_add_entities(switch_devices) - - -class HSSwitch(SwitchEntity): - """Representation of a HomeSeer switch-type device.""" - - def __init__(self, device, connection): - self._device = device - self._connection = connection - - @property - def available(self): - """Return whether the device is available.""" - return self._connection.api.state == STATE_LISTENING - - @property - def device_state_attributes(self): - attr = { - "Device Ref": self._device.ref, - "Location": self._device.location, - "Location 2": self._device.location2, - } - return attr - - @property - def unique_id(self): - """Return a unique ID for the device.""" - return f"{self._connection.namespace}-{self._device.ref}" - - @property - def name(self): - """Return the name of the device.""" - return self._connection.name_template.async_render(device=self._device).strip() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_on(self): - """Return true if device is on.""" - return self._device.is_on - - async def async_turn_on(self, **kwargs): - await self._device.on() - - async def async_turn_off(self, **kwargs): - await self._device.off() - - async def async_added_to_hass(self): - """Register value update callback.""" - self._device.register_update_callback(self.async_schedule_update_ha_state)