├── .gitignore ├── custom_components ├── __init__.py └── salus │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── config_flow.py │ ├── __init__.py │ ├── binary_sensor.py │ ├── sensor.py │ ├── switch.py │ ├── cover.py │ └── climate.py ├── hacs.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | ./venv -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/salus/const.py: -------------------------------------------------------------------------------- 1 | """Constants of the Salus iT600 component.""" 2 | 3 | DOMAIN = "salus" 4 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Salus iT600", 3 | "render_readme": true, 4 | "iot_class": "Local Polling", 5 | "domains": ["climate", "binary_sensor", "switch", "cover", "sensor"] 6 | } 7 | -------------------------------------------------------------------------------- /custom_components/salus/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "salus", 3 | "name": "Salus iT600", 4 | "config_flow": true, 5 | "documentation": "https://github.com/leonardpitzu/homeassistant_salus", 6 | "issue_tracker": "https://github.com/leonardpitzu/homeassistant_salus/issues", 7 | "requirements": ["pyit600==0.5.1"], 8 | "dependencies": [], 9 | "codeowners": [ 10 | "@leonardpitzu" 11 | ], 12 | "version": "0.5.3" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/salus/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "flow_title": "Salus: {name}", 4 | "step": { 5 | "user": { 6 | "title": "Connect to Salus Gateway", 7 | "description": "You will need IP address and first 16 symbols of the EUID (without spaces), written down on the bottom of your gateway under microUSB port (eg. 001E5E0D32906128).", 8 | "data": { 9 | "host": "IP Address", 10 | "token": "EUID", 11 | "name": "Name of the Gateway" 12 | } 13 | } 14 | }, 15 | "error": { 16 | "connect_error": "Failed to connect, please check IP address", 17 | "auth_error": "Failed to connect, please check EUID" 18 | }, 19 | "abort": { 20 | "already_configured": "Device is already configured", 21 | "already_in_progress": "Config flow for this Salus device is already in progress." 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /custom_components/salus/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "already_in_progress": "Config flow for this Salus device is already in progress." 6 | }, 7 | "error": { 8 | "auth_error": "Failed to connect, please check EUID", 9 | "connect_error": "Failed to connect, please check IP address" 10 | }, 11 | "flow_title": "Salus: {name}", 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "IP Address", 16 | "name": "Name of the Gateway", 17 | "token": "EUID" 18 | }, 19 | "description": "You will need IP address and first 16 symbols the EUID (without spaces), written down on the bottom of your gateway under microUSB port (eg. 001E5E0D32906128).", 20 | "title": "Connect to Salus Gateway" 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Konrad Banachowicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # HomeAssistant - Salus Controls iT600 Smart Home Custom Component 6 | 7 | # What This Is 8 | 9 | This is a custom component to allows you to control and monitor your Salus iT600 smart home devices locally through Salus Controls UGE600 / UGE600 universal gateway. 10 | 11 | # Supported devices 12 | 13 | See the [readme of underlying pyit600 library](https://github.com/epoplavskis/pyit600/blob/master/README.md) 14 | 15 | # Installation and Configuration 16 | 17 | ## HACS (recommended) 18 | 19 | This card is available in [HACS](https://hacs.xyz/) (Home Assistant Community Store). 20 | *HACS is a third party community store and is not included in Home Assistant out of the box.* 21 | 22 | ## Manual install 23 | Copy `custom_components` folder from this repository to `/config` of your Home Assistant instalation. 24 | 25 | To configure this integration, go to Home Assistant web interface Configuration -> Integrations and then press "+" button and select "Salus iT600". 26 | 27 | When you are done with configuration you should see your devices in Configuration -> Integrations -> Entities 28 | 29 | # Troubleshooting 30 | 31 | If you can't connect using EUID written down on the bottom of your gateway (which looks something like `001E5E0D32906128`), try using `0000000000000000` as EUID. 32 | 33 | Also check if you have "Local Wifi Mode" enabled: 34 | * Open Smart Home app on your phone 35 | * Sign in 36 | * Double tap your Gateway to open info screen 37 | * Press gear icon to enter configuration 38 | * Scroll down a bit and check if "Disable Local WiFi Mode" is set to "No" 39 | * Scroll all the way down and save settings 40 | * Restart Gateway by unplugging/plugging USB power 41 | -------------------------------------------------------------------------------- /custom_components/salus/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure Salus iT600 component.""" 2 | import logging 3 | 4 | import voluptuous as vol 5 | 6 | from homeassistant import config_entries 7 | from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN 8 | 9 | from pyit600.exceptions import IT600AuthenticationError, IT600ConnectionError 10 | from pyit600.gateway import IT600Gateway 11 | 12 | # pylint: disable=unused-import 13 | from .const import DOMAIN 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | CONF_FLOW_TYPE = "config_flow_device" 18 | CONF_USER = "user" 19 | DEFAULT_GATEWAY_NAME = "Salus iT600 Gateway" 20 | 21 | GATEWAY_SETTINGS = { 22 | vol.Required(CONF_HOST): str, 23 | vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=16, max=16)), 24 | vol.Optional(CONF_NAME, default=DEFAULT_GATEWAY_NAME): str, 25 | } 26 | 27 | 28 | class SalusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 29 | """Handle a Salus config flow.""" 30 | 31 | VERSION = 1 32 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 33 | 34 | async def async_step_user(self, user_input=None): 35 | """Handle a flow initialized by the user to configure a gateway.""" 36 | errors = {} 37 | if user_input is not None: 38 | token = user_input[CONF_TOKEN] 39 | host = user_input[CONF_HOST] 40 | 41 | # Try to connect to a Salus Gateway. 42 | gateway = IT600Gateway(host=host, euid=token) 43 | try: 44 | unique_id = await gateway.connect() 45 | await self.async_set_unique_id(unique_id) 46 | self._abort_if_unique_id_configured() 47 | return self.async_create_entry( 48 | title=user_input[CONF_NAME], 49 | data={ 50 | CONF_FLOW_TYPE: CONF_USER, 51 | CONF_HOST: host, 52 | CONF_TOKEN: token, 53 | "mac": unique_id, 54 | }, 55 | ) 56 | except IT600ConnectionError: 57 | errors["base"] = "connect_error" 58 | except IT600AuthenticationError: 59 | errors["base"] = "auth_error" 60 | 61 | schema = vol.Schema(GATEWAY_SETTINGS) 62 | 63 | return self.async_show_form(step_id="user", data_schema=schema, errors=errors) 64 | -------------------------------------------------------------------------------- /custom_components/salus/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Salus iT600.""" 2 | import logging 3 | import time 4 | from asyncio import sleep 5 | 6 | from homeassistant import config_entries, core 7 | from homeassistant.helpers import device_registry as dr 8 | 9 | from homeassistant.const import ( 10 | CONF_HOST, 11 | CONF_TOKEN 12 | ) 13 | 14 | from pyit600.exceptions import IT600AuthenticationError, IT600ConnectionError 15 | from pyit600.gateway import IT600Gateway 16 | 17 | from .config_flow import CONF_FLOW_TYPE, CONF_USER 18 | from .const import DOMAIN 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | GATEWAY_PLATFORMS = ["climate", "binary_sensor", "switch", "cover", "sensor"] 23 | 24 | 25 | async def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 26 | """Set up the Salus iT600 component.""" 27 | return True 28 | 29 | 30 | async def async_setup_entry(hass: core.HomeAssistant, entry: config_entries.ConfigEntry) -> bool: 31 | """Set up components from a config entry.""" 32 | hass.data[DOMAIN] = {} 33 | if entry.data[CONF_FLOW_TYPE] == CONF_USER: 34 | if not await async_setup_gateway_entry(hass, entry): 35 | return False 36 | 37 | return True 38 | 39 | 40 | async def async_setup_gateway_entry(hass: core.HomeAssistant, entry: config_entries.ConfigEntry) -> bool: 41 | """Set up the Gateway component from a config entry.""" 42 | host = entry.data[CONF_HOST] 43 | euid = entry.data[CONF_TOKEN] 44 | 45 | # Connect to gateway 46 | gateway = IT600Gateway(host=host, euid=euid) 47 | try: 48 | for remaining_attempts in reversed(range(3)): 49 | try: 50 | await gateway.connect() 51 | await gateway.poll_status() 52 | except Exception as e: 53 | if remaining_attempts == 0: 54 | raise e 55 | else: 56 | await sleep(3) 57 | except IT600ConnectionError as ce: 58 | _LOGGER.error("Connection error: check if you have specified gateway's HOST correctly.") 59 | return False 60 | except IT600AuthenticationError as ae: 61 | _LOGGER.error("Authentication error: check if you have specified gateway's EUID correctly.") 62 | return False 63 | 64 | hass.data[DOMAIN][entry.entry_id] = gateway 65 | 66 | gateway_info = gateway.get_gateway_device() 67 | 68 | device_registry = dr.async_get(hass) 69 | device_registry.async_get_or_create( 70 | config_entry_id=entry.entry_id, 71 | connections={(dr.CONNECTION_NETWORK_MAC, gateway_info.unique_id)}, 72 | identifiers={(DOMAIN, gateway_info.unique_id)}, 73 | manufacturer=gateway_info.manufacturer, 74 | name=gateway_info.name, 75 | model=gateway_info.model, 76 | sw_version=gateway_info.sw_version, 77 | ) 78 | 79 | await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) 80 | 81 | return True 82 | 83 | async def async_unload_entry(hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: 84 | """Unload a config entry.""" 85 | 86 | unload_ok = await hass.config_entries.async_unload_platforms( 87 | config_entry, GATEWAY_PLATFORMS 88 | ) 89 | 90 | if unload_ok: 91 | hass.data[DOMAIN].pop(config_entry.entry_id) 92 | 93 | return unload_ok 94 | -------------------------------------------------------------------------------- /custom_components/salus/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for binary (door/window/smoke/leak) sensors.""" 2 | from datetime import timedelta 3 | import logging 4 | import async_timeout 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 8 | 9 | from homeassistant.const import ( 10 | CONF_HOST, 11 | CONF_TOKEN 12 | ) 13 | 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 22 | { 23 | vol.Required(CONF_HOST): cv.string, 24 | vol.Required(CONF_TOKEN): cv.string, 25 | } 26 | ) 27 | 28 | 29 | async def async_setup_entry(hass, config_entry, async_add_entities): 30 | """Set up Salus binary sensors from a config entry.""" 31 | 32 | gateway = hass.data[DOMAIN][config_entry.entry_id] 33 | 34 | async def async_update_data(): 35 | """Fetch data from API endpoint. 36 | 37 | This is the place to pre-process the data to lookup tables 38 | so entities can quickly look up their data. 39 | """ 40 | async with async_timeout.timeout(10): 41 | await gateway.poll_status() 42 | return gateway.get_binary_sensor_devices() 43 | 44 | coordinator = DataUpdateCoordinator( 45 | hass, 46 | _LOGGER, 47 | # Name of the data. For logging purposes. 48 | name="sensor", 49 | update_method=async_update_data, 50 | # Polling interval. Will only be polled if there are subscribers. 51 | update_interval=timedelta(seconds=30), 52 | ) 53 | 54 | # Fetch initial data so we have data when entities subscribe 55 | await coordinator.async_refresh() 56 | 57 | async_add_entities(SalusBinarySensor(coordinator, idx, gateway) for idx 58 | in coordinator.data) 59 | 60 | 61 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 62 | """Set up the binary_sensor platform.""" 63 | pass 64 | 65 | 66 | class SalusBinarySensor(BinarySensorEntity): 67 | """Representation of a binary sensor.""" 68 | 69 | def __init__(self, coordinator, idx, gateway): 70 | """Initialize the sensor.""" 71 | self._coordinator = coordinator 72 | self._idx = idx 73 | self._gateway = gateway 74 | 75 | async def async_update(self): 76 | """Update the entity. 77 | Only used by the generic entity update service. 78 | """ 79 | await self._coordinator.async_request_refresh() 80 | 81 | async def async_added_to_hass(self): 82 | """When entity is added to hass.""" 83 | self.async_on_remove( 84 | self._coordinator.async_add_listener(self.async_write_ha_state) 85 | ) 86 | 87 | @property 88 | def available(self): 89 | """Return if entity is available.""" 90 | return self._coordinator.data.get(self._idx).available 91 | 92 | @property 93 | def device_info(self): 94 | """Return the device info.""" 95 | return { 96 | "name": self._coordinator.data.get(self._idx).name, 97 | "identifiers": {("salus", self._coordinator.data.get(self._idx).unique_id)}, 98 | "manufacturer": self._coordinator.data.get(self._idx).manufacturer, 99 | "model": self._coordinator.data.get(self._idx).model, 100 | "sw_version": self._coordinator.data.get(self._idx).sw_version 101 | } 102 | 103 | @property 104 | def unique_id(self): 105 | """Return the unique id.""" 106 | return self._coordinator.data.get(self._idx).unique_id 107 | 108 | @property 109 | def should_poll(self): 110 | """No need to poll. Coordinator notifies entity of updates.""" 111 | return False 112 | 113 | @property 114 | def name(self): 115 | """Return the name of the sensor.""" 116 | return self._coordinator.data.get(self._idx).name 117 | 118 | @property 119 | def is_on(self): 120 | """Return the state of the sensor.""" 121 | return self._coordinator.data.get(self._idx).is_on 122 | 123 | @property 124 | def device_class(self): 125 | """Return the device class of the sensor.""" 126 | return self._coordinator.data.get(self._idx).device_class 127 | -------------------------------------------------------------------------------- /custom_components/salus/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for (temperature, not thermostat) sensors.""" 2 | from datetime import timedelta 3 | import logging 4 | import async_timeout 5 | 6 | import voluptuous as vol 7 | from homeassistant.helpers.entity import Entity 8 | from homeassistant.components.sensor import PLATFORM_SCHEMA 9 | 10 | from homeassistant.const import ( 11 | CONF_HOST, 12 | CONF_TOKEN 13 | ) 14 | 15 | import homeassistant.helpers.config_validation as cv 16 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 17 | 18 | from .const import DOMAIN 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 23 | { 24 | vol.Required(CONF_HOST): cv.string, 25 | vol.Required(CONF_TOKEN): cv.string, 26 | } 27 | ) 28 | 29 | 30 | async def async_setup_entry(hass, config_entry, async_add_entities): 31 | """Set up Salus sensors from a config entry.""" 32 | 33 | gateway = hass.data[DOMAIN][config_entry.entry_id] 34 | 35 | async def async_update_data(): 36 | """Fetch data from API endpoint. 37 | 38 | This is the place to pre-process the data to lookup tables 39 | so entities can quickly look up their data. 40 | """ 41 | async with async_timeout.timeout(10): 42 | await gateway.poll_status() 43 | return gateway.get_sensor_devices() 44 | 45 | coordinator = DataUpdateCoordinator( 46 | hass, 47 | _LOGGER, 48 | # Name of the data. For logging purposes. 49 | name="sensor", 50 | update_method=async_update_data, 51 | # Polling interval. Will only be polled if there are subscribers. 52 | update_interval=timedelta(seconds=30), 53 | ) 54 | 55 | # Fetch initial data so we have data when entities subscribe 56 | await coordinator.async_refresh() 57 | 58 | async_add_entities(SalusSensor(coordinator, idx, gateway) for idx 59 | in coordinator.data) 60 | 61 | 62 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 63 | """Set up the sensor platform.""" 64 | pass 65 | 66 | 67 | class SalusSensor(Entity): 68 | """Representation of a sensor.""" 69 | 70 | def __init__(self, coordinator, idx, gateway): 71 | """Initialize the sensor.""" 72 | self._coordinator = coordinator 73 | self._idx = idx 74 | self._gateway = gateway 75 | 76 | async def async_update(self): 77 | """Update the entity. 78 | Only used by the generic entity update service. 79 | """ 80 | await self._coordinator.async_request_refresh() 81 | 82 | async def async_added_to_hass(self): 83 | """When entity is added to hass.""" 84 | self.async_on_remove( 85 | self._coordinator.async_add_listener(self.async_write_ha_state) 86 | ) 87 | 88 | def available(self): 89 | """Return if entity is available.""" 90 | return self._coordinator.data.get(self._idx).available 91 | 92 | @property 93 | def device_info(self): 94 | """Return the device info.""" 95 | return { 96 | "name": self._coordinator.data.get(self._idx).name, 97 | "identifiers": {("salus", self._coordinator.data.get(self._idx).unique_id)}, 98 | "manufacturer": self._coordinator.data.get(self._idx).manufacturer, 99 | "model": self._coordinator.data.get(self._idx).model, 100 | "sw_version": self._coordinator.data.get(self._idx).sw_version 101 | } 102 | 103 | @property 104 | def unique_id(self): 105 | """Return the unique id.""" 106 | return self._coordinator.data.get(self._idx).unique_id 107 | 108 | @property 109 | def should_poll(self): 110 | """No need to poll. Coordinator notifies entity of updates.""" 111 | return False 112 | 113 | @property 114 | def name(self): 115 | """Return the name of the sensor.""" 116 | return self._coordinator.data.get(self._idx).name 117 | 118 | @property 119 | def state(self): 120 | """Return the state of the sensor.""" 121 | return self._coordinator.data.get(self._idx).state 122 | 123 | @property 124 | def unit_of_measurement(self): 125 | """Return the unit of measurement of this entity, if any.""" 126 | return self._coordinator.data.get(self._idx).unit_of_measurement 127 | 128 | @property 129 | def device_class(self): 130 | """Return the device class of the sensor.""" 131 | return self._coordinator.data.get(self._idx).device_class -------------------------------------------------------------------------------- /custom_components/salus/switch.py: -------------------------------------------------------------------------------- 1 | """Support for switch (smart plug/relay/roller shutter) devices.""" 2 | from datetime import timedelta 3 | import logging 4 | import async_timeout 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity 8 | 9 | from homeassistant.const import ( 10 | CONF_HOST, 11 | CONF_TOKEN 12 | ) 13 | 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 22 | { 23 | vol.Required(CONF_HOST): cv.string, 24 | vol.Required(CONF_TOKEN): cv.string, 25 | } 26 | ) 27 | 28 | 29 | async def async_setup_entry(hass, config_entry, async_add_entities): 30 | """Set up Salus switches from a config entry.""" 31 | 32 | gateway = hass.data[DOMAIN][config_entry.entry_id] 33 | 34 | async def async_update_data(): 35 | """Fetch data from API endpoint. 36 | 37 | This is the place to pre-process the data to lookup tables 38 | so entities can quickly look up their data. 39 | """ 40 | async with async_timeout.timeout(10): 41 | await gateway.poll_status() 42 | return gateway.get_switch_devices() 43 | 44 | coordinator = DataUpdateCoordinator( 45 | hass, 46 | _LOGGER, 47 | # Name of the data. For logging purposes. 48 | name="sensor", 49 | update_method=async_update_data, 50 | # Polling interval. Will only be polled if there are subscribers. 51 | update_interval=timedelta(seconds=30), 52 | ) 53 | 54 | # Fetch initial data so we have data when entities subscribe 55 | await coordinator.async_refresh() 56 | 57 | async_add_entities(SalusSwitch(coordinator, idx, gateway) for idx 58 | in coordinator.data) 59 | 60 | 61 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 62 | """Set up the switch platform.""" 63 | pass 64 | 65 | 66 | class SalusSwitch(SwitchEntity): 67 | """Representation of a switch.""" 68 | 69 | def __init__(self, coordinator, idx, gateway): 70 | """Initialize the sensor.""" 71 | self._coordinator = coordinator 72 | self._idx = idx 73 | self._gateway = gateway 74 | 75 | async def async_update(self): 76 | """Update the entity. 77 | Only used by the generic entity update service. 78 | """ 79 | await self._coordinator.async_request_refresh() 80 | 81 | async def async_added_to_hass(self): 82 | """When entity is added to hass.""" 83 | self.async_on_remove( 84 | self._coordinator.async_add_listener(self.async_write_ha_state) 85 | ) 86 | 87 | def available(self): 88 | """Return if entity is available.""" 89 | return self._coordinator.data.get(self._idx).available 90 | 91 | @property 92 | def device_info(self): 93 | """Return the device info.""" 94 | return { 95 | "name": self._coordinator.data.get(self._idx).name, 96 | "identifiers": {("salus", self._coordinator.data.get(self._idx).unique_id)}, 97 | "manufacturer": self._coordinator.data.get(self._idx).manufacturer, 98 | "model": self._coordinator.data.get(self._idx).model, 99 | "sw_version": self._coordinator.data.get(self._idx).sw_version 100 | } 101 | 102 | @property 103 | def unique_id(self): 104 | """Return the unique id.""" 105 | return self._coordinator.data.get(self._idx).unique_id 106 | 107 | @property 108 | def should_poll(self): 109 | """No need to poll. Coordinator notifies entity of updates.""" 110 | return False 111 | 112 | @property 113 | def device_class(self): 114 | """Return the device class of the sensor.""" 115 | return self._coordinator.data.get(self._idx).device_class 116 | 117 | @property 118 | def is_on(self): 119 | """Return true if it is on.""" 120 | return self._coordinator.data.get(self._idx).is_on 121 | 122 | async def async_turn_on(self, **kwargs): 123 | """Turn the switch on.""" 124 | await self._gateway.turn_on_switch_device(self._idx) 125 | await self._coordinator.async_request_refresh() 126 | 127 | async def async_turn_off(self, **kwargs): 128 | """Turn the switch off.""" 129 | await self._gateway.turn_off_switch_device(self._idx) 130 | await self._coordinator.async_request_refresh() 131 | -------------------------------------------------------------------------------- /custom_components/salus/cover.py: -------------------------------------------------------------------------------- 1 | """Support for cover (roller shutter) devices.""" 2 | from datetime import timedelta 3 | import logging 4 | import async_timeout 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.cover import PLATFORM_SCHEMA, ATTR_POSITION, CoverEntity 8 | 9 | from homeassistant.const import ( 10 | CONF_HOST, 11 | CONF_TOKEN 12 | ) 13 | 14 | import homeassistant.helpers.config_validation as cv 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 22 | { 23 | vol.Required(CONF_HOST): cv.string, 24 | vol.Required(CONF_TOKEN): cv.string, 25 | } 26 | ) 27 | 28 | 29 | async def async_setup_entry(hass, config_entry, async_add_entities): 30 | """Set up Salus cover devices from a config entry.""" 31 | 32 | gateway = hass.data[DOMAIN][config_entry.entry_id] 33 | 34 | async def async_update_data(): 35 | """Fetch data from API endpoint. 36 | 37 | This is the place to pre-process the data to lookup tables 38 | so entities can quickly look up their data. 39 | """ 40 | async with async_timeout.timeout(10): 41 | await gateway.poll_status() 42 | return gateway.get_cover_devices() 43 | 44 | coordinator = DataUpdateCoordinator( 45 | hass, 46 | _LOGGER, 47 | # Name of the data. For logging purposes. 48 | name="sensor", 49 | update_method=async_update_data, 50 | # Polling interval. Will only be polled if there are subscribers. 51 | update_interval=timedelta(seconds=30), 52 | ) 53 | 54 | # Fetch initial data so we have data when entities subscribe 55 | await coordinator.async_refresh() 56 | 57 | async_add_entities(SalusCover(coordinator, idx, gateway) for idx 58 | in coordinator.data) 59 | 60 | 61 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 62 | """Set up the cover platform.""" 63 | pass 64 | 65 | 66 | class SalusCover(CoverEntity): 67 | """Representation of a binary sensor.""" 68 | 69 | def __init__(self, coordinator, idx, gateway): 70 | """Initialize the sensor.""" 71 | self._coordinator = coordinator 72 | self._idx = idx 73 | self._gateway = gateway 74 | 75 | async def async_update(self): 76 | """Update the entity. 77 | Only used by the generic entity update service. 78 | """ 79 | await self._coordinator.async_request_refresh() 80 | 81 | async def async_added_to_hass(self): 82 | """When entity is added to hass.""" 83 | self.async_on_remove( 84 | self._coordinator.async_add_listener(self.async_write_ha_state) 85 | ) 86 | 87 | @property 88 | def available(self): 89 | """Return if entity is available.""" 90 | return self._coordinator.data.get(self._idx).available 91 | 92 | @property 93 | def device_info(self): 94 | """Return the device info.""" 95 | return { 96 | "name": self._coordinator.data.get(self._idx).name, 97 | "identifiers": {("salus", self._coordinator.data.get(self._idx).unique_id)}, 98 | "manufacturer": self._coordinator.data.get(self._idx).manufacturer, 99 | "model": self._coordinator.data.get(self._idx).model, 100 | "sw_version": self._coordinator.data.get(self._idx).sw_version 101 | } 102 | 103 | @property 104 | def unique_id(self): 105 | """Return the unique id.""" 106 | return self._coordinator.data.get(self._idx).unique_id 107 | 108 | @property 109 | def should_poll(self): 110 | """No need to poll. Coordinator notifies entity of updates.""" 111 | return False 112 | 113 | @property 114 | def name(self): 115 | """Return the name of the sensor.""" 116 | return self._coordinator.data.get(self._idx).name 117 | 118 | @property 119 | def supported_features(self): 120 | """Return the list of supported features.""" 121 | return self._coordinator.data.get(self._idx).supported_features 122 | 123 | @property 124 | def device_class(self): 125 | """Return the device class of the sensor.""" 126 | return self._coordinator.data.get(self._idx).device_class 127 | 128 | @property 129 | def current_cover_position(self): 130 | """Return the current position of the cover.""" 131 | return self._coordinator.data.get(self._idx).current_cover_position 132 | 133 | @property 134 | def is_opening(self): 135 | """Return if the cover is opening or not.""" 136 | return self._coordinator.data.get(self._idx).is_opening 137 | 138 | @property 139 | def is_closing(self): 140 | """Return if the cover is closing or not.""" 141 | return self._coordinator.data.get(self._idx).is_closing 142 | 143 | @property 144 | def is_closed(self): 145 | """Return if the cover is closed.""" 146 | return self._coordinator.data.get(self._idx).is_closed 147 | 148 | async def async_open_cover(self, **kwargs): 149 | """Open the cover.""" 150 | await self._gateway.open_cover(self._idx) 151 | await self._coordinator.async_request_refresh() 152 | 153 | async def async_close_cover(self, **kwargs): 154 | """Close the cover.""" 155 | await self._gateway.close_cover(self._idx) 156 | await self._coordinator.async_request_refresh() 157 | 158 | async def async_set_cover_position(self, **kwargs): 159 | """Move the cover to a specific position.""" 160 | position = kwargs.get(ATTR_POSITION) 161 | if position is None: 162 | return 163 | await self._gateway.set_cover_position(self._idx, position) 164 | await self._coordinator.async_request_refresh() 165 | -------------------------------------------------------------------------------- /custom_components/salus/climate.py: -------------------------------------------------------------------------------- 1 | """Support for climate devices (thermostats).""" 2 | from datetime import timedelta 3 | import logging 4 | import async_timeout 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity 8 | from homeassistant.components.climate.const import ( 9 | HVACMode, 10 | ClimateEntityFeature, 11 | FAN_OFF, 12 | FAN_AUTO, 13 | FAN_LOW, 14 | FAN_MEDIUM, 15 | FAN_HIGH 16 | ) 17 | from homeassistant.const import ( 18 | ATTR_TEMPERATURE, 19 | CONF_HOST, 20 | CONF_TOKEN 21 | ) 22 | import homeassistant.helpers.config_validation as cv 23 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 24 | 25 | from .const import DOMAIN 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 30 | { 31 | vol.Required(CONF_HOST): cv.string, 32 | vol.Required(CONF_TOKEN): cv.string, 33 | } 34 | ) 35 | 36 | 37 | async def async_setup_entry(hass, config_entry, async_add_entities): 38 | """Set up Salus thermostats from a config entry.""" 39 | 40 | gateway = hass.data[DOMAIN][config_entry.entry_id] 41 | 42 | async def async_update_data(): 43 | """Fetch data from API endpoint. 44 | 45 | This is the place to pre-process the data to lookup tables 46 | so entities can quickly look up their data. 47 | """ 48 | async with async_timeout.timeout(10): 49 | await gateway.poll_status() 50 | return gateway.get_climate_devices() 51 | 52 | coordinator = DataUpdateCoordinator( 53 | hass, 54 | _LOGGER, 55 | # Name of the data. For logging purposes. 56 | name="sensor", 57 | update_method=async_update_data, 58 | # Polling interval. Will only be polled if there are subscribers. 59 | update_interval=timedelta(seconds=30), 60 | ) 61 | 62 | # Fetch initial data so we have data when entities subscribe 63 | await coordinator.async_refresh() 64 | 65 | async_add_entities(SalusThermostat(coordinator, idx, gateway) for idx 66 | in coordinator.data) 67 | 68 | 69 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 70 | """Set up the sensor platform.""" 71 | pass 72 | 73 | 74 | class SalusThermostat(ClimateEntity): 75 | """Representation of a Sensor.""" 76 | 77 | def __init__(self, coordinator, idx, gateway): 78 | """Initialize the thermostat.""" 79 | self._coordinator = coordinator 80 | self._idx = idx 81 | self._gateway = gateway 82 | 83 | async def async_update(self): 84 | """Update the entity. 85 | Only used by the generic entity update service. 86 | """ 87 | await self._coordinator.async_request_refresh() 88 | 89 | async def async_added_to_hass(self): 90 | """When entity is added to hass.""" 91 | self.async_on_remove( 92 | self._coordinator.async_add_listener(self.async_write_ha_state) 93 | ) 94 | 95 | @property 96 | def supported_features(self): 97 | """Return the list of supported features.""" 98 | # definetly needs a better approach 99 | # return self._coordinator.data.get(self._idx).supported_features 100 | 101 | supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE 102 | return supported_features 103 | 104 | @property 105 | def available(self): 106 | """Return if entity is available.""" 107 | return self._coordinator.data.get(self._idx).available 108 | 109 | @property 110 | def device_info(self): 111 | """Return the device info.""" 112 | return { 113 | "name": self._coordinator.data.get(self._idx).name, 114 | "identifiers": {("salus", self._coordinator.data.get(self._idx).unique_id)}, 115 | "manufacturer": self._coordinator.data.get(self._idx).manufacturer, 116 | "model": self._coordinator.data.get(self._idx).model, 117 | "sw_version": self._coordinator.data.get(self._idx).sw_version 118 | } 119 | 120 | @property 121 | def unique_id(self): 122 | """Return the unique id.""" 123 | return self._coordinator.data.get(self._idx).unique_id 124 | 125 | @property 126 | def should_poll(self): 127 | """No need to poll. Coordinator notifies entity of updates.""" 128 | return False 129 | 130 | @property 131 | def name(self): 132 | """Return the name of the Radio Thermostat.""" 133 | return self._coordinator.data.get(self._idx).name 134 | 135 | @property 136 | def temperature_unit(self): 137 | """Return the unit of measurement.""" 138 | return self._coordinator.data.get(self._idx).temperature_unit 139 | 140 | @property 141 | def precision(self): 142 | """Return the precision of the system.""" 143 | return self._coordinator.data.get(self._idx).precision 144 | 145 | @property 146 | def current_temperature(self): 147 | """Return the current temperature.""" 148 | return self._coordinator.data.get(self._idx).current_temperature 149 | 150 | @property 151 | def current_humidity(self): 152 | """Return the current humidity.""" 153 | return self._coordinator.data.get(self._idx).current_humidity 154 | 155 | @property 156 | def hvac_mode(self): 157 | """Return the current operation. head, cool idle.""" 158 | return self._coordinator.data.get(self._idx).hvac_mode 159 | 160 | @property 161 | def hvac_modes(self): 162 | """Return the operation modes list.""" 163 | return self._coordinator.data.get(self._idx).hvac_modes 164 | 165 | @property 166 | def hvac_action(self): 167 | """Return the current running hvac operation if supported.""" 168 | return self._coordinator.data.get(self._idx).hvac_action 169 | 170 | @property 171 | def target_temperature(self): 172 | """Return the temperature we try to reach.""" 173 | return self._coordinator.data.get(self._idx).target_temperature 174 | 175 | @property 176 | def max_temp(self): 177 | return self._coordinator.data.get(self._idx).max_temp 178 | 179 | @property 180 | def min_temp(self): 181 | return self._coordinator.data.get(self._idx).min_temp 182 | 183 | @property 184 | def preset_mode(self): 185 | return self._coordinator.data.get(self._idx).preset_mode 186 | 187 | @property 188 | def preset_modes(self): 189 | return self._coordinator.data.get(self._idx).preset_modes 190 | 191 | @property 192 | def fan_mode(self): 193 | return self._coordinator.data.get(self._idx).fan_mode 194 | 195 | @property 196 | def fan_modes(self): 197 | return self._coordinator.data.get(self._idx).fan_modes 198 | 199 | @property 200 | def locked(self): 201 | return self._coordinator.data.get(self._idx).locked 202 | 203 | async def async_set_temperature(self, **kwargs): 204 | """Set new target temperature.""" 205 | temperature = kwargs.get(ATTR_TEMPERATURE) 206 | if temperature is None: 207 | return 208 | await self._gateway.set_climate_device_temperature(self._idx, temperature) 209 | await self._coordinator.async_request_refresh() 210 | 211 | # TODO: Not listed in methods here https://developers.home-assistant.io/docs/core/entity/climate/#methods 212 | # async def async_set_locked(self, locked): 213 | # """Set locked (true, false).""" 214 | # await self._gateway.set_climate_device_locked(self._idx, locked) 215 | # await self._coordinator.async_request_refresh() 216 | 217 | async def async_set_fan_mode(self, fan_mode): 218 | """Set fan speed (auto, low, medium, high, off).""" 219 | _LOGGER.info('set_fan_mode: ' + str(fan_mode)) 220 | if fan_mode == FAN_OFF: 221 | mode = "Off" 222 | elif fan_mode == FAN_LOW: 223 | mode = "Low" 224 | elif fan_mode == FAN_MEDIUM: 225 | mode = "Medium" 226 | elif fan_mode == FAN_HIGH: 227 | mode = "High" 228 | else: 229 | mode = "Auto" 230 | await self._gateway.set_climate_device_fan_mode(self._idx, mode) 231 | await self._coordinator.async_request_refresh() 232 | 233 | async def async_set_hvac_mode(self, hvac_mode): 234 | """Set operation mode (auto, heat, cool).""" 235 | if hvac_mode == HVACMode.HEAT: 236 | mode = "heat" 237 | elif hvac_mode == HVACMode.COOL: 238 | mode = "cool" 239 | else: 240 | mode = "auto" 241 | await self._gateway.set_climate_device_mode(self._idx, mode) 242 | await self._coordinator.async_request_refresh() 243 | 244 | async def async_set_preset_mode(self, preset_mode): 245 | """Set preset mode (Off, Permanent Hold, Eco, Temporary Hold, Follow Schedule)""" 246 | await self._gateway.set_climate_device_preset(self._idx, preset_mode) 247 | await self._coordinator.async_request_refresh() 248 | --------------------------------------------------------------------------------