├── .gitignore ├── LICENCE ├── README.md ├── custom_components └── uhomeuponor │ ├── __init__.py │ ├── climate.py │ ├── config_flow.py │ ├── manifest.json │ ├── sensor.py │ ├── translations │ ├── en.json │ └── es.json │ └── uponor_api │ ├── __init__.py │ ├── const.py │ └── utilities.py ├── docs └── openHub instructions.txt └── hacs.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Python temp files 2 | __pycache__/ 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uhome Uponor integration for Home Assistant 2 | 3 | Buy Me A Coffee 4 | 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) 6 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/hacs/integration) 7 | 8 | Uhome Uponor is a python custom_component for connect Home Assistant with Uponor Smatrix Wave PLUS Smart Home Gateway, R-167 aka U@home. The module uses units REST API for discovery of controllers and thermostats. 9 | 10 | # Installation 11 | 12 | ## Using HACS 13 | 14 | - Setup and configure your system on Uponor Smatrix mobile app 15 | 16 | - Add this custom repository to your HACS Community store, as an `Integration`: 17 | 18 | > dave-code-ruiz/uhomeuponor 19 | 20 | - Then find the `Uhome Uponor` integration and install it. 21 | 22 | - HACS request you restart home assistant. 23 | 24 | - Go to Configuration > Integration > Add Integration > Uhome Uponor. Finish the setup configuration. 25 | 26 | ## Manual 27 | 28 | - Copy content of custom_components directory in this repository, into your HA custom_components directory. 29 | 30 | - Restart Home Assistant. 31 | 32 | - Go to Configuration > Integration > Add Integration > Uhome Uponor. Finish the setup configuration. 33 | 34 | # Configuration 35 | 36 | #### IMPORTANT! If you have old configuration in configuration.yaml, please remove it, remove all old entities and restart HA before config new integration. 37 | 38 | host: 192.168.x.x 39 | 40 | prefix: [your prefix name] # Optional, prefix name for climate entities 41 | 42 | supports_heating: True # Optional, set to False to exclude Heating as an HVAC Mode 43 | 44 | supports_cooling: True # Optional, set to False to exclude Cooling as an HVAC Mode 45 | 46 | Currently this module creates the following entities, for each thermostat: 47 | 48 | * Climate: 49 | * A `climate` control entity 50 | * Sensor: 51 | * A `temperature` sensor 52 | * A `humidity` sensor 53 | * A `battery` sensor 54 | 55 | # Scheduler 56 | 57 | I recomended use Scheduler component to program set point thermostats temperature: 58 | 59 | > https://github.com/nielsfaber/scheduler-component 60 | 61 | ## Contributions 62 | 63 | Thanks to @almirdelkic for API code. 64 | Thanks to @lordmike for upgrade the code with great ideas. 65 | 66 | # New module X-265 / R-208 67 | 68 | For new module Uponor X-265 / R-208 visit: 69 | 70 | https://github.com/dave-code-ruiz/uponorx265 71 | 72 | # Hardware compatibility list 73 | 74 | The module has been tested with following hardware: 75 | 76 | * X-165 (controller) 77 | * M-160 (slave module) 78 | * I-167 (panel) 79 | * R-167 (U@home module) 80 | * T-169 (thermostat) 81 | * T-161 (thermostat) 82 | * T-165 (thermostat) 83 | 84 | If you test it with other units, please let me know or even better update the list above. 85 | 86 | Donate 87 | ============= 88 | [![paypal](https://www.paypalobjects.com/en_US/ES/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=5U5L9S4SP79FJ&item_name=Create+more+code+and+components+in+github+and+Home+Assistant¤cy_code=EUR&source=url) 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/__init__.py: -------------------------------------------------------------------------------- 1 | """Uponor U@Home integration 2 | 3 | For more details about this platform, please refer to the documentation at 4 | https://github.com/fcastroruiz/uhomeuponor 5 | """ 6 | from logging import getLogger 7 | import asyncio 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.const import Platform 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.helpers import device_registry, entity_registry 12 | from .uponor_api.const import DOMAIN 13 | 14 | _LOGGER = getLogger(__name__) 15 | 16 | PLATFORMS = [Platform.SENSOR, Platform.CLIMATE] 17 | 18 | async def async_setup(hass: HomeAssistant, config: dict): 19 | """Set up this integration using UI.""" 20 | hass.data.setdefault(DOMAIN, {}) 21 | hass.data[DOMAIN]["config"] = config.get(DOMAIN) or {} 22 | return True 23 | 24 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): 25 | """Set up this integration using UI.""" 26 | _LOGGER.info("Loading setup entry") 27 | 28 | # hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) 29 | if config_entry.options: 30 | if config_entry.data != config_entry.options: 31 | dev_reg = device_registry.async_get(hass) 32 | ent_reg = entity_registry.async_get(hass) 33 | dev_reg.async_clear_config_entry(config_entry.entry_id) 34 | ent_reg.async_clear_config_entry(config_entry.entry_id) 35 | hass.config_entries.async_update_entry(config_entry, data=config_entry.options) 36 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 37 | 38 | config_entry.async_on_unload(config_entry.add_update_listener(async_update_options)) 39 | 40 | return True 41 | 42 | async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: 43 | """Update options.""" 44 | _LOGGER.debug("Update setup entry: %s, data: %s, options: %s", entry.entry_id, entry.data, entry.options) 45 | await hass.config_entries.async_reload(entry.entry_id) 46 | 47 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 48 | """Unload a config entry.""" 49 | _LOGGER.debug("Unloading setup entry: %s, data: %s, options: %s", config_entry.entry_id, config_entry.data, config_entry.options) 50 | unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 51 | return unload_ok 52 | #if unload_ok: 53 | # hass.data[DOMAIN].pop(config_entry.entry_id) 54 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/climate.py: -------------------------------------------------------------------------------- 1 | """Uponor U@Home integration 2 | Exposes Climate control entities for Uponor thermostats 3 | 4 | - UponorThermostat 5 | """ 6 | 7 | import voluptuous as vol 8 | 9 | from requests.exceptions import RequestException 10 | 11 | from homeassistant.exceptions import PlatformNotReady 12 | from homeassistant.components.climate import ClimateEntity 13 | from homeassistant.components.climate.const import ( 14 | HVACMode, PRESET_COMFORT, PRESET_ECO, HVACAction, ClimateEntityFeature) 15 | from homeassistant.const import (ATTR_TEMPERATURE, CONF_HOST, CONF_PREFIX, PRECISION_TENTHS, UnitOfTemperature) 16 | from logging import getLogger 17 | 18 | from .uponor_api import UponorClient 19 | from .uponor_api.const import (DOMAIN, UHOME_MODE_HEAT, UHOME_MODE_COOL, UHOME_MODE_ECO, UHOME_MODE_COMFORT) 20 | 21 | CONF_SUPPORTS_HEATING = "supports_heating" 22 | CONF_SUPPORTS_COOLING = "supports_cooling" 23 | 24 | ATTR_TECHNICAL_ALARM = "technical_alarm" 25 | ATTR_RF_SIGNAL_ALARM = "rf_alarm" 26 | ATTR_BATTERY_ALARM = "battery_alarm" 27 | ATTR_REMOTE_ACCESS_ALARM = "remote_access_alarm" 28 | ATTR_DEVICE_LOST_ALARM = "device_lost_alarm" 29 | 30 | _LOGGER = getLogger(__name__) 31 | 32 | async def async_setup_entry(hass, config_entry, async_add_entities): 33 | _LOGGER.info("init setup climate platform for id: %s data: %s, options: %s", config_entry.entry_id, config_entry.data, config_entry.options) 34 | config = config_entry.data 35 | return await async_setup_climate( 36 | hass, config, async_add_entities, discovery_info=None 37 | ) 38 | 39 | async def async_setup_climate( 40 | hass, config, async_add_entities, discovery_info=None 41 | ) -> bool: 42 | """Set up climate for device.""" 43 | host = config[CONF_HOST] 44 | prefix = config[CONF_PREFIX] 45 | supports_heating = config[CONF_SUPPORTS_HEATING] or True 46 | supports_cooling = config[CONF_SUPPORTS_COOLING] or True 47 | 48 | _LOGGER.info("init setup host %s", host) 49 | 50 | uponor = await hass.async_add_executor_job(lambda: UponorClient(hass=hass, server=host)) 51 | try: 52 | await uponor.rescan() 53 | except (ValueError, RequestException) as err: 54 | _LOGGER.error("Received error from UHOME: %s", err) 55 | raise PlatformNotReady 56 | 57 | async_add_entities([UponorThermostat(prefix, uponor, thermostat, supports_heating, supports_cooling) 58 | for thermostat in uponor.thermostats], True) 59 | 60 | _LOGGER.info("finish setup climate platform for Uhome Uponor") 61 | return True 62 | 63 | class UponorThermostat(ClimateEntity): 64 | """HA Thermostat climate entity. Utilizes Uponor U@Home API to interact with U@Home""" 65 | 66 | def __init__(self, prefix, uponor_client, thermostat, supports_heating, supports_cooling): 67 | self._available = False 68 | self.prefix = prefix 69 | self.uponor_client = uponor_client 70 | self.thermostat = thermostat 71 | self.supports_heating = supports_heating 72 | self.supports_cooling = supports_cooling 73 | self.device_name = f"{prefix or ''}{thermostat.by_name('room_name').value}" 74 | self.device_id = f"{prefix or ''}controller{str(thermostat.controller_index)}_thermostat{str(thermostat.thermostat_index)}" 75 | self.identity = f"{prefix or ''}controller{str(thermostat.controller_index)}_thermostat{str(thermostat.thermostat_index)}_thermostat" 76 | 77 | @property 78 | def device_info(self) -> dict: 79 | """Return info for device registry.""" 80 | return { 81 | "identifiers": {(DOMAIN, self.device_id)}, 82 | "name": self.device_name, 83 | } 84 | 85 | # ** Generic ** 86 | @property 87 | def name(self): 88 | return f"{self.prefix or ''}{self.thermostat.by_name('room_name').value}" 89 | 90 | @property 91 | def unique_id(self): 92 | return self.identity 93 | 94 | @property 95 | def available(self): 96 | return self._available 97 | 98 | # ** Static ** 99 | @property 100 | def temperature_unit(self): 101 | return UnitOfTemperature.CELSIUS 102 | 103 | @property 104 | def precision(self): 105 | return PRECISION_TENTHS 106 | 107 | @property 108 | def target_temperature_step(self): 109 | return '0.5' 110 | 111 | @property 112 | def supported_features(self): 113 | return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE 114 | 115 | @property 116 | def hvac_modes(self): 117 | modes = [] 118 | 119 | if self.supports_heating: 120 | modes.append(HVACMode.HEAT) 121 | 122 | if self.supports_cooling: 123 | modes.append(HVACMode.COOL) 124 | 125 | return modes 126 | 127 | @property 128 | def preset_modes(self): 129 | return [PRESET_ECO, PRESET_COMFORT] 130 | 131 | # ** State ** 132 | @property 133 | def current_humidity(self): 134 | return self.thermostat.by_name('rh_value').value 135 | 136 | @property 137 | def current_temperature(self): 138 | return self.thermostat.by_name('room_temperature').value 139 | 140 | @property 141 | def target_temperature(self): 142 | return self.thermostat.by_name('room_setpoint').value 143 | 144 | @property 145 | def extra_state_attributes(self): 146 | return { 147 | ATTR_TECHNICAL_ALARM: self.thermostat.by_name(ATTR_TECHNICAL_ALARM).value, 148 | ATTR_RF_SIGNAL_ALARM: self.thermostat.by_name(ATTR_RF_SIGNAL_ALARM).value, 149 | ATTR_BATTERY_ALARM: self.thermostat.by_name(ATTR_BATTERY_ALARM).value, 150 | ATTR_REMOTE_ACCESS_ALARM: self.uponor_client.uhome.by_name(ATTR_REMOTE_ACCESS_ALARM).value, 151 | ATTR_DEVICE_LOST_ALARM: self.uponor_client.uhome.by_name(ATTR_DEVICE_LOST_ALARM).value 152 | } 153 | 154 | @property 155 | def preset_mode(self): 156 | if self.uponor_client.uhome.by_name('forced_eco_mode').value == 1: 157 | return PRESET_ECO 158 | 159 | return PRESET_COMFORT 160 | 161 | @property 162 | def hvac_mode(self): 163 | if self.uponor_client.uhome.by_name('hc_mode').value == 1: 164 | return HVACMode.COOL 165 | 166 | return HVACMode.HEAT 167 | 168 | @property 169 | def hvac_action(self): 170 | if self.thermostat.by_name('room_in_demand').value == 0: 171 | return HVACAction.IDLE 172 | 173 | if self.hvac_mode == HVACMode.HEAT: 174 | return HVACAction.HEATING 175 | else: 176 | return HVACAction.COOLING 177 | 178 | # ** Actions ** 179 | async def async_update(self): 180 | # Update Uhome (to get HC mode) and thermostat 181 | try: 182 | await self.uponor_client.update_devices(self.uponor_client.uhome, self.thermostat) 183 | valid = self.thermostat.is_valid() 184 | self._available = valid 185 | if not valid: 186 | _LOGGER.debug("The thermostat '%s' had invalid data, and is therefore unavailable", self.identity) 187 | except Exception as ex: 188 | self._available = False 189 | _LOGGER.error("Uponor thermostat was unable to update: %s", ex) 190 | 191 | async def async_set_hvac_mode(self, hvac_mode): 192 | if hvac_mode == HVACMode.HEAT: 193 | value = UHOME_MODE_HEAT 194 | else: 195 | value = UHOME_MODE_COOL 196 | await self.thermostat.set_hvac_mode(value) 197 | 198 | # Support setting preset_mode 199 | async def async_set_preset_mode(self, preset_mode): 200 | if preset_mode == PRESET_ECO: 201 | value = UHOME_MODE_ECO 202 | else: 203 | value = UHOME_MODE_COMFORT 204 | await self.thermostat.set_preset_mode(value) 205 | await self.thermostat.set_auto_mode() 206 | 207 | async def async_set_temperature(self, **kwargs): 208 | if kwargs.get(ATTR_TEMPERATURE) is None: 209 | return 210 | await self.thermostat.set_setpoint(kwargs.get(ATTR_TEMPERATURE)) 211 | 212 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/config_flow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | from homeassistant import config_entries 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import callback 6 | import logging 7 | import voluptuous as vol 8 | from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PREFIX) 9 | from .uponor_api.const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | CONF_SUPPORTS_HEATING = "supports_heating" 14 | CONF_SUPPORTS_COOLING = "supports_cooling" 15 | 16 | class UhomeuponorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 17 | """Uponor config flow.""" 18 | VERSION = 1 19 | # CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 20 | 21 | async def async_step_user(self, user_input=None): 22 | errors = {} 23 | _LOGGER.info("Init config step uhomeuponor") 24 | if self._async_current_entries(): 25 | return self.async_abort(reason="single_instance_allowed") 26 | if user_input is not None: 27 | _LOGGER.debug("user_input: %s", user_input) 28 | # Validate user input 29 | #valid = await is_valid(user_input) 30 | #if valid: 31 | #title = f"{self.info[CONF_HOST]} - {self.device_id}" 32 | title = f"Uhome Uponor" 33 | data={ 34 | CONF_HOST: user_input[CONF_HOST], 35 | CONF_PREFIX: (user_input.get(CONF_PREFIX) if user_input.get(CONF_PREFIX) else ""), 36 | CONF_SUPPORTS_HEATING: user_input[CONF_SUPPORTS_HEATING], 37 | CONF_SUPPORTS_COOLING: user_input[CONF_SUPPORTS_COOLING]} 38 | return self.async_create_entry( 39 | title=title, 40 | data=data 41 | # options=data 42 | ) 43 | 44 | return self.async_show_form( 45 | step_id="user", 46 | data_schema=vol.Schema( 47 | { 48 | vol.Required(CONF_HOST): str, 49 | vol.Optional(CONF_PREFIX): str, 50 | vol.Optional(CONF_SUPPORTS_HEATING, default=True): bool, 51 | vol.Optional(CONF_SUPPORTS_COOLING, default=True): bool, 52 | } 53 | ), errors=errors 54 | ) 55 | 56 | @staticmethod 57 | @callback 58 | def async_get_options_flow(entry: config_entries.ConfigEntry): 59 | return OptionsFlowHandler(entry) 60 | 61 | class OptionsFlowHandler(config_entries.OptionsFlow): 62 | 63 | def __init__(self, config_entry): 64 | """Initialize options flow.""" 65 | self.config_entry = config_entry 66 | 67 | async def async_step_init(self, _user_input=None): 68 | """Manage the options.""" 69 | return await self.async_step_user() 70 | 71 | async def async_step_user(self, user_input=None): 72 | """Handle a flow initialized by the user.""" 73 | _LOGGER.debug("entra en step user: %s", user_input) 74 | _LOGGER.info("Init Option config step uhomeuponor") 75 | errors = {} 76 | options = self.config_entry.data 77 | if user_input is not None: 78 | data={ 79 | CONF_HOST: user_input[CONF_HOST], 80 | CONF_PREFIX: user_input[CONF_PREFIX], 81 | CONF_SUPPORTS_HEATING: user_input[CONF_SUPPORTS_HEATING], 82 | CONF_SUPPORTS_COOLING: user_input[CONF_SUPPORTS_COOLING], 83 | } 84 | _LOGGER.debug("user_input data: %s, id: %s", data, self.config_entry.entry_id) 85 | title = f"Uhome Uponor" 86 | return self.async_create_entry( 87 | title=title, 88 | data=data 89 | ) 90 | 91 | return self.async_show_form( 92 | step_id="user", 93 | data_schema=vol.Schema( 94 | { 95 | vol.Required(CONF_HOST, default=options.get(CONF_HOST)): str, 96 | vol.Optional(CONF_PREFIX, default=options.get(CONF_PREFIX)): str, 97 | vol.Optional(CONF_SUPPORTS_HEATING, default=options.get(CONF_SUPPORTS_HEATING)): bool, 98 | vol.Optional(CONF_SUPPORTS_COOLING, default=options.get(CONF_SUPPORTS_COOLING)): bool, 99 | } 100 | ), errors=errors 101 | ) 102 | 103 | # class UhomeuponorDicoveryFlow(DiscoveryFlowHandler[Awaitable[bool]], domain=DOMAIN): 104 | # """Discovery flow handler.""" 105 | 106 | # VERSION = 1 107 | 108 | # def __init__(self) -> None: 109 | # """Set up config flow.""" 110 | # super().__init__( 111 | # DOMAIN, 112 | # "Uponor Checker", 113 | # _async_supported, 114 | # ) 115 | 116 | # async def async_step_onboarding( 117 | # self, data: dict[str, Any] | None = None 118 | # ) -> FlowResult: 119 | # """Handle a flow initialized by onboarding.""" 120 | # has_devices = await self._discovery_function(self.hass) 121 | 122 | # if not has_devices: 123 | # return self.async_abort(reason="no_devices_found") 124 | # return self.async_create_entry(title=self._title, data={}) -------------------------------------------------------------------------------- /custom_components/uhomeuponor/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "uhomeuponor", 3 | "name": "Uhome Uponor", 4 | "version": "1.0.0", 5 | "documentation": "https://github.com/dave-code-ruiz/uhomeuponor", 6 | "issue_tracker": "https://github.com/dave-code-ruiz/uhomeuponor/issues", 7 | "dependencies": [], 8 | "license": "GNU GPLv3", 9 | "config_flow": true, 10 | "iot_class": "local_polling", 11 | "codeowners": ["@almirdelkic", "@dave-code-ruiz", "@LordMike"], 12 | "requirements": [] 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/sensor.py: -------------------------------------------------------------------------------- 1 | """Uponor U@Home integration 2 | Exposes Sensors for Uponor devices, such as: 3 | 4 | - Temperature (UponorThermostatTemperatureSensor) 5 | - Humidity (UponorThermostatHumiditySensor) 6 | - Battery (UponorThermostatBatterySensor) 7 | """ 8 | 9 | import voluptuous as vol 10 | 11 | from requests.exceptions import RequestException 12 | 13 | from homeassistant.exceptions import PlatformNotReady 14 | from homeassistant.components.sensor import (PLATFORM_SCHEMA, SensorDeviceClass, SensorStateClass) 15 | from homeassistant.const import (CONF_HOST, CONF_PREFIX, ATTR_ATTRIBUTION, UnitOfTemperature) 16 | import homeassistant.helpers.config_validation as cv 17 | from logging import getLogger 18 | from homeassistant.components.sensor import SensorEntity 19 | 20 | from .uponor_api import UponorClient 21 | from .uponor_api.const import (DOMAIN, UNIT_BATTERY, UNIT_HUMIDITY) 22 | 23 | _LOGGER = getLogger(__name__) 24 | 25 | DEFAULT_NAME = 'Uhome Uponor' 26 | 27 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 28 | vol.Required(CONF_HOST): cv.string, 29 | vol.Optional(CONF_PREFIX): cv.string, 30 | }) 31 | 32 | 33 | async def async_setup_entry(hass, config_entry, async_add_entities): 34 | _LOGGER.info("init setup sensor platform for id: %s data: %s, options: %s", config_entry.entry_id, config_entry.data, config_entry.options) 35 | config = config_entry.data 36 | return await async_setup_sensor( 37 | hass, config, async_add_entities, discovery_info=None 38 | ) 39 | 40 | async def async_setup_sensor( 41 | hass, config, async_add_entities, discovery_info=None 42 | ) -> bool: 43 | 44 | host = config[CONF_HOST] 45 | prefix = config[CONF_PREFIX] 46 | 47 | _LOGGER.info("init setup host %s", host) 48 | 49 | uponor = await hass.async_add_executor_job(lambda: UponorClient(hass=hass, server=host)) 50 | try: 51 | await uponor.rescan() 52 | except (ValueError, RequestException) as err: 53 | _LOGGER.error("Received error from UHOME: %s", err) 54 | raise PlatformNotReady 55 | 56 | async_add_entities([UponorThermostatTemperatureSensor(prefix, uponor, thermostat) 57 | for thermostat in uponor.thermostats], True) 58 | 59 | async_add_entities([UponorThermostatHumiditySensor(prefix, uponor, thermostat) 60 | for thermostat in uponor.thermostats], True) 61 | 62 | async_add_entities([UponorThermostatBatterySensor(prefix, uponor, thermostat) 63 | for thermostat in uponor.thermostats], True) 64 | 65 | _LOGGER.info("finish setup sensor platform for Uhome Uponor") 66 | return True 67 | 68 | class UponorThermostatTemperatureSensor(SensorEntity): 69 | """HA Temperature sensor entity. Utilizes Uponor U@Home API to interact with U@Home""" 70 | 71 | def __init__(self, prefix, uponor_client, thermostat): 72 | self._available = False 73 | self.prefix = prefix 74 | self.uponor_client = uponor_client 75 | self.thermostat = thermostat 76 | self.device_name = f"{prefix or ''}{thermostat.by_name('room_name').value}" 77 | self.device_id = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}" 78 | self.identity = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}_temp" 79 | 80 | @property 81 | def device_info(self) -> dict: 82 | """Return info for device registry.""" 83 | return { 84 | "identifiers": {(DOMAIN, self.device_id)}, 85 | "name": self.device_name, 86 | } 87 | 88 | # ** Generic ** 89 | @property 90 | def name(self): 91 | return f"{self.prefix or ''}{self.thermostat.by_name('room_name').value}" 92 | 93 | @property 94 | def unique_id(self): 95 | return self.identity 96 | 97 | @property 98 | def icon(self): 99 | return 'mdi:thermometer' 100 | 101 | @property 102 | def available(self): 103 | return self._available 104 | 105 | # ** DEBUG PROPERTY ** 106 | # @property 107 | # def extra_state_attributes(self): 108 | # """Return the device state attributes.""" 109 | # attr = self.thermostat.attributes() + self.uponor_client.uhome.attributes() 110 | # return { 111 | # ATTR_ATTRIBUTION: attr, 112 | # } 113 | 114 | # ** Static ** 115 | @property 116 | def unit_of_measurement(self): 117 | return UnitOfTemperature.CELSIUS 118 | 119 | @property 120 | def device_class(self): 121 | return SensorDeviceClass.TEMPERATURE 122 | 123 | @property 124 | def state_class(self): 125 | return SensorStateClass.MEASUREMENT 126 | 127 | # ** State ** 128 | @property 129 | def state(self): 130 | return self.thermostat.by_name('room_temperature').value 131 | 132 | # ** Actions ** 133 | async def async_update(self): 134 | # Update thermostat 135 | try: 136 | await self.thermostat.async_update() 137 | valid = self.thermostat.is_valid() 138 | self._available = valid 139 | if not valid: 140 | _LOGGER.debug("The thermostat temperature sensor '%s' had invalid data, and is therefore unavailable", self.identity) 141 | except Exception as ex: 142 | self._available = False 143 | _LOGGER.error("Uponor thermostat temperature sensor was unable to update: %s", ex) 144 | 145 | class UponorThermostatHumiditySensor(SensorEntity): 146 | """HA Humidity sensor entity. Utilizes Uponor U@Home API to interact with U@Home""" 147 | 148 | def __init__(self, prefix, uponor_client, thermostat): 149 | self._available = False 150 | self.prefix = prefix 151 | self.uponor_client = uponor_client 152 | self.thermostat = thermostat 153 | self.device_name = f"{prefix or ''}{thermostat.by_name('room_name').value}" 154 | self.device_id = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}" 155 | self.identity = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}_rh" 156 | 157 | @property 158 | def device_info(self) -> dict: 159 | """Return info for device registry.""" 160 | return { 161 | "identifiers": {(DOMAIN, self.device_id)}, 162 | "name": self.device_name, 163 | } 164 | 165 | # ** Generic ** 166 | @property 167 | def name(self): 168 | return f"{self.prefix or ''}{self.thermostat.by_name('room_name').value} Humidity" 169 | 170 | @property 171 | def unique_id(self): 172 | return self.identity 173 | 174 | @property 175 | def icon(self): 176 | return 'mdi:water-percent' 177 | 178 | @property 179 | def available(self): 180 | return self._available 181 | 182 | # ** Static ** 183 | @property 184 | def unit_of_measurement(self): 185 | return UNIT_HUMIDITY 186 | 187 | @property 188 | def device_class(self): 189 | return SensorDeviceClass.HUMIDITY 190 | 191 | @property 192 | def state_class(self): 193 | return SensorStateClass.MEASUREMENT 194 | 195 | # ** State ** 196 | @property 197 | def state(self): 198 | return self.thermostat.by_name('rh_value').value 199 | 200 | # ** Actions ** 201 | async def async_update(self): 202 | # Update thermostat 203 | try: 204 | await self.thermostat.async_update() 205 | valid = self.thermostat.is_valid() 206 | self._available = valid 207 | 208 | if not valid: 209 | _LOGGER.debug("The thermostat humidity sensor '%s' had invalid data, and is therefore unavailable", self.identity) 210 | except Exception as ex: 211 | self._available = False 212 | _LOGGER.error("Uponor thermostat humidity sensor was unable to update: %s", ex) 213 | 214 | class UponorThermostatBatterySensor(SensorEntity): 215 | """HA Battery sensor entity. Utilizes Uponor U@Home API to interact with U@Home""" 216 | 217 | def __init__(self, prefix, uponor_client, thermostat): 218 | self._available = False 219 | self.prefix = prefix 220 | self.uponor_client = uponor_client 221 | self.thermostat = thermostat 222 | self.device_name = f"{prefix or ''}{thermostat.by_name('room_name').value}" 223 | self.device_id = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}" 224 | self.identity = f"{prefix or ''}controller{thermostat.controller_index}_thermostat{thermostat.thermostat_index}_batt" 225 | 226 | @property 227 | def device_info(self) -> dict: 228 | """Return info for device registry.""" 229 | return { 230 | "identifiers": {(DOMAIN, self.device_id)}, 231 | "name": self.device_name, 232 | } 233 | 234 | # ** Generic ** 235 | @property 236 | def name(self): 237 | return f"{self.prefix or ''}{self.thermostat.by_name('room_name').value} Battery" 238 | 239 | @property 240 | def unique_id(self): 241 | return self.identity 242 | 243 | @property 244 | def available(self): 245 | return self._available 246 | 247 | # ** Static ** 248 | @property 249 | def unit_of_measurement(self): 250 | return UNIT_BATTERY 251 | 252 | @property 253 | def device_class(self): 254 | return SensorDeviceClass.BATTERY 255 | 256 | # ** State ** 257 | @property 258 | def state(self): 259 | # If there is a battery alarm, report a low level - else report 100% 260 | if self.thermostat.by_name('battery_alarm').value == 1: 261 | return 10 262 | 263 | return 100 264 | 265 | # ** Actions ** 266 | async def async_update(self): 267 | # Update thermostat 268 | try: 269 | await self.thermostat.async_update() 270 | valid = self.thermostat.is_valid() 271 | self._available = valid 272 | 273 | if not valid: 274 | _LOGGER.debug("The thermostat battery sensor '%s' had invalid data, and is therefore unavailable", self.identity) 275 | except Exception as ex: 276 | self._available = False 277 | _LOGGER.error("Uponor thermostat battery sensor was unable to update: %s", ex) 278 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Already configured. Only a single configuration possible." 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_api_key": "Invalid API key", 9 | "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Smatrix Uponor", 14 | "description": "Config Uponor system", 15 | "data": { 16 | "host": "Host or IP address of the Uponor Gateway", 17 | "prefix": "Entities prefix", 18 | "supports_heating": "Uponor supports heating", 19 | "supports_cooling": "Uponor supports cooling" 20 | } 21 | } 22 | } 23 | }, 24 | "options": { 25 | "step": { 26 | "user": { 27 | "title": "Smatrix Uponor", 28 | "description": "Config Uponor system", 29 | "data": { 30 | "host": "Host or IP address of the Uponor Gateway", 31 | "prefix": "Entities prefix", 32 | "supports_heating": "Uponor supports heating", 33 | "supports_cooling": "Uponor supports cooling" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." 5 | }, 6 | "error": { 7 | "cannot_connect": "No se pudo conectar", 8 | "invalid_api_key": "Clave API no v\u00e1lida", 9 | "requests_exceeded": "Se ha excedido el n\u00famero permitido de solicitudes a la API de Accuweather. Tienes que esperar o cambiar la Clave API." 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Smatrix Uponor", 14 | "description": "Configurar Uponor", 15 | "data": { 16 | "host": "Host o dirección IP del Gateway Uponor", 17 | "prefix": "Prefijo para las entidades", 18 | "supports_heating": "Uponor soporta calentar", 19 | "supports_cooling": "Uponor soporta refrigerar" 20 | } 21 | } 22 | } 23 | }, 24 | "options": { 25 | "step": { 26 | "user": { 27 | "title": "Smatrix Uponor", 28 | "description": "Configurar Uponor", 29 | "data": { 30 | "host": "Host o dirección IP del Gateway Uponor", 31 | "prefix": "Prefijo para las entidades", 32 | "supports_heating": "Uponor soporta calentar", 33 | "supports_cooling": "Uponor soporta refrigerar" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/uponor_api/__init__.py: -------------------------------------------------------------------------------- 1 | """UHome Uponor API client""" 2 | 3 | import logging 4 | import requests 5 | import json 6 | 7 | from datetime import datetime, timedelta 8 | from abc import ABC, abstractmethod 9 | 10 | from .const import * 11 | from .utilities import * 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | class UponorAPIException(Exception): 16 | def __init__(self, message, inner_exception=None): 17 | if inner_exception: 18 | super().__init__(f"{message}: {inner_exception}") 19 | else: 20 | super().__init__(message) 21 | self.inner_exception = inner_exception 22 | 23 | class UponorClient(object): 24 | """API Client for Uponor U@Home API""" 25 | 26 | def __init__(self, hass, server): 27 | self.hass = hass 28 | self.server = server 29 | self.uhome = UponorUhome(self) 30 | self.controllers = [] 31 | self.thermostats = [] 32 | 33 | self.max_update_interval = timedelta(seconds=60) 34 | self.max_values_batch = 40 35 | 36 | self.server_uri = f"http://{self.server}/api" 37 | 38 | async def rescan(self): 39 | # Initialize 40 | await self.uhome.async_update() 41 | await self.init_controllers() 42 | await self.init_thermostats() 43 | 44 | async def init_controllers(self): 45 | """ 46 | Identifies present controllers from U@Home. 47 | """ 48 | 49 | self.controllers.clear() 50 | 51 | # A value of 3 (0011) will indicate that controllers 0 (0001) and 1 (0010) are present 52 | bitMask = self.uhome.by_name("controller_presence").value 53 | 54 | for i in range(0, 4): 55 | mask = 1 << i 56 | 57 | if bitMask & mask: 58 | # Controller i is present 59 | self.controllers.append(UponorController(self, i)) 60 | 61 | #_LOGGER.debug("Identified %d controllers", len(self.controllers)) 62 | 63 | # Update all controllers 64 | await self.update_devices(self.controllers) 65 | 66 | async def init_thermostats(self): 67 | """ 68 | Identifies present thermostats from U@Home. 69 | """ 70 | 71 | self.thermostats.clear() 72 | 73 | # A value of 31 (0000 0001 1111) will indicate that thermostats 0 (0000 0000 0001) through 4 (0000 0001 0000) are present 74 | for controller in self.controllers: 75 | bitMask = controller.by_name('thermostat_presence').value 76 | 77 | for i in range(0, 12): 78 | mask = 1 << i 79 | 80 | if bitMask & mask: 81 | # Thermostat i is present 82 | self.thermostats.append(UponorThermostat(self, controller.controller_index, i)) 83 | 84 | #_LOGGER.debug("Identified %d thermostats on %d controllers", len(self.thermostats), len(self.controllers)) 85 | 86 | # Update all thermostats 87 | await self.update_devices(self.thermostats) 88 | 89 | def create_request(self, method): 90 | req = { 91 | 'jsonrpc': "2.0", 92 | 'id': 8, 93 | 'method': method, 94 | 'params': { 95 | 'objects': [] 96 | } 97 | } 98 | 99 | return req 100 | 101 | def add_request_object(self, req, obj): 102 | req['params']['objects'].append(obj) 103 | 104 | async def do_rest_call(self, requestObject): 105 | data = json.dumps(requestObject) 106 | 107 | response = None 108 | try: 109 | response = await self.hass.async_add_executor_job(lambda: requests.post(self.server_uri, data=data)) 110 | except requests.exceptions.RequestException as ex: 111 | raise UponorAPIException("API call error", ex) 112 | 113 | if response.status_code != 200: 114 | raise UponorAPIException("Unsucessful API call") 115 | 116 | response_data = json.loads(response.text) 117 | 118 | #_LOGGER.debug("Issued API request type '%s' for %d objects, return code %d", requestObject['method'], len(requestObject['params']['objects']), response.status_code) 119 | 120 | return response_data 121 | 122 | async def update_devices(self, *devices): 123 | """Updates all values of all devices provided by making API calls. Only devices not updated recently will be considered""" 124 | devices = flatten(devices) 125 | 126 | # Filter devices to include devices if either: 127 | # - Device has never been updated 128 | # - Device was last updated max_update_interval time ago 129 | devices_to_update = [device for device in devices if (not device.pending_update and (device.last_update is None or (datetime.now() - device.last_update) > self.max_update_interval))] 130 | 131 | if len(devices_to_update) == 0: 132 | return 133 | 134 | values = [] 135 | for device in devices_to_update: 136 | values.extend(device.properties_byid.values()) 137 | device.pending_update = True 138 | 139 | #Create dict for all devices 140 | allvalues = [] 141 | for device in self.thermostats: 142 | allvalues.extend(device.properties_byid.values()) 143 | allvalues = flatten(allvalues) 144 | allvalue_dict = {} 145 | for value in allvalues: 146 | allvalue_dict[value.id] = value 147 | 148 | #_LOGGER.debug("Requested update %d values of %d devices, skipped %d devices", len(values), len(devices_to_update), len(devices) - len(devices_to_update)) 149 | 150 | try: 151 | # Update all values, but at most N at a time 152 | for value_list in chunks(values, self.max_values_batch): 153 | await self.update_values(allvalue_dict, value_list) 154 | except Exception as e: 155 | _LOGGER.exception(e) 156 | for device in devices_to_update: 157 | device.pending_update = False 158 | raise 159 | 160 | for device in devices_to_update: 161 | device.last_update = datetime.now() 162 | device.pending_update = False 163 | 164 | async def update_values(self, allvalue_dict, *values): 165 | """Updates all values provided by making API calls""" 166 | values = flatten(values) 167 | 168 | if len(values) == 0: 169 | return 170 | 171 | #_LOGGER.debug("Requested update of %d values", len(values)) 172 | 173 | value_dict = {} 174 | for value in values: 175 | value_dict[value.id] = value 176 | 177 | req = self.create_request("read") 178 | for value in values: 179 | obj = {'id': str(value.id), 'properties': {str(value.property): {}}} 180 | self.add_request_object(req, obj) 181 | 182 | response_data = await self.do_rest_call(req) 183 | 184 | if self.validate_values(response_data, allvalue_dict): 185 | for obj in response_data['result']['objects']: 186 | try: 187 | data_id = int(obj['id']) 188 | value = value_dict[data_id] 189 | data_val = obj['properties'][value.property]['value'] 190 | except Exception as e: 191 | continue 192 | 193 | value.value = data_val 194 | 195 | def getStepValue(self, id, therm): 196 | #Obtain addr of THERMOSTAT_KEY, thermostatindex and controllerindex 197 | #Obtain step jump between thermostats (40,80,..) 198 | c=0 199 | t=0 200 | step=0 201 | for i in range(4): 202 | if id > 500: 203 | id=id-500 204 | c=c+1 205 | id=id-80 206 | for i in range(9): 207 | if id > 40: 208 | id=id-40 209 | t=t+1 210 | data_addr=id, t, c 211 | if data_addr[0] in (11,25,28): 212 | nextt=0 213 | for t in therm: 214 | if nextt==1: 215 | nextt=t 216 | if t[0] == data_addr[1] and t[1] == data_addr[2]: 217 | nextt=1 218 | if nextt != 0 and nextt !=1 and nextt[1] == data_addr[2]: 219 | step=(nextt[0]-data_addr[1])*40 220 | return step 221 | 222 | def validate_values(self,response_data,allvalue_dict): 223 | 224 | #Function to detect same values errors 225 | #api sometimes generate response errors that show values of the next thermostat 226 | #this function evaluate response and detect if values are values of the next thermostat, in that case, values do not sets 227 | samevalue = 0 228 | therm=[] 229 | for thermostat in self.thermostats: 230 | therm.append([thermostat.thermostat_index,thermostat.controller_index]) 231 | for obj in response_data['result']['objects']: 232 | try: 233 | data_id = int(obj['id']) 234 | value = allvalue_dict[data_id] 235 | data_val = obj['properties'][value.property]['value'] 236 | step=self.getStepValue(data_id,therm) 237 | #only is necesary validate values in addrs 11,25,28, rest of values do not change 238 | if step != 0: 239 | if allvalue_dict[data_id]: 240 | oldvalue=allvalue_dict[data_id] 241 | if allvalue_dict[data_id+step]: 242 | nextvalue=allvalue_dict[data_id+step] 243 | if nextvalue.value == data_val: 244 | samevalue=samevalue+1 245 | else: 246 | res=nextvalue.value-oldvalue.value 247 | if res > 0: 248 | if res >= 1 and str(data_id)[len(str(data_id))-1:len(str(data_id))] != '8': 249 | res = res*3/4 250 | if data_val > oldvalue.value+res: 251 | samevalue=samevalue+1 252 | else: 253 | res=res*-1 254 | if res >= 1 and str(data_id)[len(str(data_id))-1:len(str(data_id))] != '8': 255 | res = res*3/4 256 | if data_val < oldvalue.value-res: 257 | samevalue=samevalue+1 258 | #_LOGGER.debug("Response values, id %d, value %s, samevalue %d, old %s, idnext %s, next %s",data_id,data_val,samevalue,oldvalue.value,data_id+step,nextvalue.value) 259 | 260 | except Exception as e: 261 | if '85' not in str(e) and '662' not in str(e): 262 | _LOGGER.debug("Response error %s obj %s",e,obj) 263 | continue 264 | 265 | if samevalue == 3: 266 | _LOGGER.warning("Response error in API, wrong value, not updated sensor") 267 | _LOGGER.debug("Response error in API, same value in different thermostat not updated in this response API: %s ",response_data['result']['objects']) 268 | return False 269 | else: 270 | return True 271 | 272 | async def set_values(self, *value_tuples): 273 | """Writes values to UHome, accepts tuples of (UponorValue, New Value)""" 274 | 275 | #_LOGGER.debug("Requested write to %d values", len(value_tuples)) 276 | 277 | req = self.create_request("write") 278 | 279 | for tpl in value_tuples: 280 | obj = {'id': str(tpl[0].id), 'properties': {str(tpl[0].property): {'value': str(tpl[1])}}} 281 | self.add_request_object(req, obj) 282 | 283 | await self.do_rest_call(req) 284 | 285 | # Apply new values, after the API call succeeds 286 | for tpl in value_tuples: 287 | tpl[0].value = tpl[1] 288 | 289 | class UponorValue(object): 290 | """Single value in the Uponor API""" 291 | 292 | def __init__(self, id, name, prop): 293 | self.id = id 294 | self.name = name 295 | self.value = 0 296 | self.property = prop 297 | 298 | class UponorBaseDevice(ABC): 299 | """Base device class""" 300 | 301 | def __init__(self, uponor_client, id_offset, properties, identity_string): 302 | self.uponor_client = uponor_client 303 | self.id_offset = id_offset 304 | self.properties_byname = {} 305 | self.properties_byid = {} 306 | self.properties = properties 307 | self.last_update = None 308 | self.pending_update = False 309 | self.identity_string = identity_string 310 | 311 | for key_name, key_data in properties.items(): 312 | value = UponorValue(id_offset + key_data['addr'], key_name, key_data['property']) 313 | self.properties_byid[value.id] = value 314 | self.properties_byname[value.name] = value 315 | 316 | def by_id(self, id): 317 | return self.properties_byid[id] 318 | 319 | def by_name(self, name): 320 | return self.properties_byname[name] 321 | 322 | def attributes(self): 323 | attr = None 324 | for key_name, key_data in self.properties.items(): 325 | attr = str(attr) + str(key_name) + ': ' + str(self.properties_byname[key_name].value) + '#' 326 | return attr 327 | 328 | async def async_update(self): 329 | #_LOGGER.debug("Updating %s, device '%s'", self.__class__.__name__, self.identity_string) 330 | 331 | await self.uponor_client.update_devices(self) 332 | 333 | @abstractmethod 334 | def is_valid(self): 335 | pass 336 | 337 | class UponorUhome(UponorBaseDevice): 338 | """U@Home API device class, typically an R-167""" 339 | 340 | def __init__(self, uponor_client): 341 | super().__init__(uponor_client, 0, UHOME_MODULE_KEYS, "U@Home") 342 | 343 | def is_valid(self): 344 | return True 345 | 346 | class UponorController(UponorBaseDevice): 347 | """Controller API device class, typically an X-165""" 348 | 349 | def __init__(self, uponor_client, controller_index): 350 | # Offset: 60 + 500 x c 351 | super().__init__(uponor_client, 60 + 500 * controller_index, UHOME_CONTROLLER_KEYS, str(controller_index)) 352 | 353 | self.controller_index = controller_index 354 | 355 | def is_valid(self): 356 | return True 357 | 358 | class UponorThermostat(UponorBaseDevice): 359 | """Thermostat API device class, typically an T-169""" 360 | 361 | def __init__(self, uponor_client, controller_index, thermostat_index): 362 | # Offset: 80 + 500 x c + 40 x t 363 | super().__init__(uponor_client, 80 + 500 * controller_index + 40 * thermostat_index, UHOME_THERMOSTAT_KEYS, f"{controller_index} / {thermostat_index}") 364 | self.controller_index = controller_index 365 | self.thermostat_index = thermostat_index 366 | 367 | def is_valid(self): 368 | # A Thermostat is valid if the temperature is -40<=T<=100 C* and the setpoint is 5<=S<=35 C* 369 | return -40 <= self.by_name('room_temperature').value and self.by_name('room_temperature').value <= 100 and \ 370 | 1 <= self.by_name('room_setpoint').value and self.by_name('room_setpoint').value <= 40 371 | 372 | async def set_name(self, name): 373 | """Updates the thermostats room name to a new value""" 374 | await self.uponor_client.set_values((self.by_name('room_name'), name)) 375 | 376 | async def set_setpoint(self, temperature): 377 | """Updates the thermostats setpoint to a new value""" 378 | await self.uponor_client.set_values( 379 | (self.by_name('setpoint_write_enable'), 0), 380 | (self.by_name('room_setpoint'), temperature) 381 | ) 382 | 383 | async def set_hvac_mode(self, value): 384 | """Updates the thermostats mode to a new value""" 385 | await self.uponor_client.set_values( 386 | (self.uponor_client.uhome.by_name('allow_hc_mode_change'), 0), 387 | (self.uponor_client.uhome.by_name('hc_mode'), value), 388 | ) 389 | 390 | async def set_preset_mode(self, value): 391 | """Updates the thermostats mode to a new value""" 392 | await self.uponor_client.set_values((self.uponor_client.uhome.by_name('forced_eco_mode'), value)) 393 | 394 | async def set_manual_mode(self): 395 | await self.uponor_client.set_values( 396 | (self.uponor_client.uhome.by_name('setpoint_write_enable'), 1), 397 | (self.uponor_client.uhome.by_name('rh_control_activation'), 1), 398 | (self.uponor_client.uhome.by_name('dehumidifier_control_activation'), 0), 399 | (self.uponor_client.uhome.by_name('setpoint_write_enable'), 0), 400 | ) 401 | 402 | async def set_auto_mode(self): 403 | await self.uponor_client.set_values( 404 | (self.uponor_client.uhome.by_name('setpoint_write_enable'), 1), 405 | (self.uponor_client.uhome.by_name('rh_control_activation'), 0), 406 | (self.uponor_client.uhome.by_name('dehumidifier_control_activation'), 0), 407 | (self.uponor_client.uhome.by_name('setpoint_write_enable'), 0), 408 | ) 409 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/uponor_api/const.py: -------------------------------------------------------------------------------- 1 | """Constants.""" 2 | DOMAIN = "uhomeuponor" 3 | # HC_MODEs 4 | UHOME_MODE_HEAT = '0' 5 | UHOME_MODE_COOL = '1' 6 | 7 | # PRESET_MODEs 8 | UHOME_MODE_ECO = "1" 9 | UHOME_MODE_COMFORT = "0" 10 | 11 | # Units 12 | UNIT_BATTERY = '%' 13 | UNIT_HUMIDITY = '%' 14 | 15 | # U@Home 16 | # Offset: 0 17 | UHOME_MODULE_KEYS = { 18 | 'module_id': {'addr': 20, 'value': 0, 'property': '85'}, 19 | 'cooling_available': {'addr': 21, 'value': 0, 'property': '85'}, 20 | 'holiday_mode': {'addr': 22, 'value': 0, 'property': '85'}, 21 | 'forced_eco_mode': {'addr': 23, 'value': 0, 'property': '85'}, 22 | 'hc_mode': {'addr': 24, 'value': 0, 'property': '85'}, 23 | 'hc_masterslave': {'addr': 25, 'value': 0, 'property': '85'}, 24 | 'ts_sv_version': {'addr': 26, 'value': 0, 'property': '85'}, 25 | 'holiday_setpoint': {'addr': 27, 'value': 0, 'property': '85'}, 26 | 'average_temp_low': {'addr': 28, 'value': 0, 'property': '85'}, 27 | 'low_temp_alarm_limit': {'addr': 29, 'value': 0, 'property': '85'}, 28 | 'low_temp_alarm_hysteresis': {'addr': 30, 'value': 0, 'property': '85'}, 29 | 'remote_access_alarm': {'addr': 31, 'value': 0, 'property': '662'}, 30 | 'device_lost_alarm': {'addr': 32, 'value': 0, 'property': '662'}, 31 | 'no_comm_controller1': {'addr': 33, 'value': 0, 'property': '85'}, 32 | 'no_comm_controller2': {'addr': 34, 'value': 0, 'property': '85'}, 33 | 'no_comm_controller3': {'addr': 35, 'value': 0, 'property': '85'}, 34 | 'no_comm_controller4': {'addr': 36, 'value': 0, 'property': '85'}, 35 | 'average_room_temperature': {'addr': 37, 'value': 0, 'property': '85'}, 36 | 'controller_presence': {'addr': 38, 'value': 0, 'property': '85'}, 37 | 'allow_hc_mode_change': {'addr': 39, 'value': 0, 'property': '85'}, 38 | 'hc_master_type': {'addr': 40, 'value': 0, 'property': '85'}, 39 | } 40 | 41 | # Controllers 42 | # Offset: 60 + 500 x c 43 | UHOME_CONTROLLER_KEYS = { 44 | 'output_module': {'addr': 0, 'value': 0, 'property': '85'}, 45 | 'rh_deadzone': {'addr': 1, 'value': 0, 'property': '85'}, 46 | 'controller_sv_version': {'addr': 2, 'value': 0, 'property': '85'}, 47 | 'thermostat_presence': {'addr': 3, 'value': 0, 'property': '85'}, 48 | 'supply_high_alarm': {'addr': 4, 'value': 0, 'property': '85'}, 49 | 'supply_low_alarm': {'addr': 5, 'value': 0, 'property': '85'}, 50 | 'average_room_temperature_NO': {'addr': 6, 'value': 0, 'property': '85'}, 51 | 'measured_outdoor_temperature': {'addr': 7, 'value': 0, 'property': '85'}, 52 | 'supply_temp': {'addr': 8, 'value': 0, 'property': '85'}, 53 | 'dehumidifier_status': {'addr': 9, 'value': 0, 'property': '85'}, 54 | 'outdoor_sensor_presence': {'addr': 10, 'value': 0, 'property': '85'}, 55 | } 56 | 57 | # Thermostats 58 | # Offset: 80 + 500 x c + 40 x t 59 | UHOME_THERMOSTAT_KEYS = { 60 | # 'eco_profile_active_cf': {'addr': 0, 'value': 0, 'property': '85'}, 61 | 'dehumidifier_control_activation': {'addr': 1, 'value': 0, 'property': '85'}, 62 | 'rh_control_activation': {'addr': 2, 'value': 0, 'property': '85'}, 63 | # 'eco_profile_number': {'addr': 3, 'value': 0, 'property': '85'}, 64 | 'setpoint_write_enable': {'addr': 4, 'value': 0, 'property': '85'}, 65 | # 'cooling_allowed': {'addr': 5, 'value': 0, 'property': '85'}, 66 | # 'rh_setpoint': {'addr': 6, 'value': 0, 'property': '85'}, 67 | # 'min_setpoint': {'addr': 7, 'value': 0, 'property': '85'}, 68 | # 'max_setpoint': {'addr': 8, 'value': 0, 'property': '85'}, 69 | # 'min_floor_temp': {'addr': 9, 'value': 0, 'property': '85'}, 70 | # 'max_floor_temp': {'addr': 10, 'value': 0, 'property': '85'}, 71 | 'room_setpoint': {'addr': 11, 'value': 0, 'property': '85'}, 72 | # 'eco_offset': {'addr': 12, 'value': 0, 'property': '85'}, 73 | # 'eco_profile_active': {'addr': 13, 'value': 0, 'property': '85'}, 74 | # 'home_away_mode_status': {'addr': 14, 'value': 0, 'property': '85'}, 75 | 'room_in_demand': {'addr': 15, 'value': 0, 'property': '85'}, 76 | # 'rh_limit_reached': {'addr': 16, 'value': 0, 'property': '85'}, 77 | # 'floor_limit_status': {'addr': 17, 'value': 0, 'property': '85'}, 78 | 'technical_alarm': {'addr': 18, 'value': 0, 'property': '662'}, 79 | # 'tamper_indication': {'addr': 19, 'value': 0, 'property': '662'}, 80 | 'rf_alarm': {'addr': 20, 'value': 0, 'property': '662'}, 81 | 'battery_alarm': {'addr': 21, 'value': 0, 'property': '662'}, 82 | # 'rh_sensor': {'addr': 22, 'value': 0, 'property': '85'}, 83 | # 'thermostat_type': {'addr': 23, 'value': 0, 'property': '85'}, 84 | # 'regulation_mode': {'addr': 24, 'value': 0, 'property': '85'}, 85 | 'room_temperature': {'addr': 25, 'value': 0, 'property': '85'}, 86 | # 'room_temperature_ext': {'addr': 26, 'value': 0, 'property': '85'}, 87 | 'rh_value': {'addr': 27, 'value': 0, 'property': '85'}, 88 | # 'ch_linked_to_th': {'addr': 28, 'value': 0, 'property': '85'}, 89 | 'room_name': {'addr': 29, 'value': 0, 'property': '85'}, 90 | # 'utilization_factor_24h': {'addr': 30, 'value': 0, 'property': '85'}, 91 | # 'utilization_factor_7d': {'addr': 31, 'value': 0, 'property': '85'}, 92 | # 'reg_mode': {'addr': 32, 'value': 0, 'property': '85'}, 93 | # 'channel_average': {'addr': 33, 'value': 0, 'property': '85'}, 94 | # 'radiator_heating': {'addr': 34, 'value': 0, 'property': '85'} 95 | } 96 | -------------------------------------------------------------------------------- /custom_components/uhomeuponor/uponor_api/utilities.py: -------------------------------------------------------------------------------- 1 | def flatten(*args): 2 | output = [] 3 | for arg in args: 4 | if hasattr(arg, '__iter__'): 5 | output.extend(flatten(*arg)) 6 | else: 7 | output.append(arg) 8 | return output 9 | 10 | def chunks(lst, n): 11 | """Yield successive n-sized chunks from lst.""" 12 | for i in range(0, len(lst), n): 13 | yield lst[i:i + n] -------------------------------------------------------------------------------- /docs/openHub instructions.txt: -------------------------------------------------------------------------------- 1 | Aquí están mis reglas completas para uponor: 2 | 3 | import java.util.HashMap 4 | 5 | //Create a map that map from uponor id to the item that should be updated: 6 | val HashMap itemMap = newHashMap( 7 | '84' -> BadrumSetWithApp, 8 | '91' -> BadrumNereSetPoint, 9 | '105' -> BadrumNereTemp, 10 | 11 | '124' -> PannrumSetWithApp, 12 | '131' -> PannrumSetPoint, 13 | '145' -> PannrumTemp, 14 | 15 | '164' -> SminkrumSetWithApp, 16 | '171' -> SminkrumSetPoint, 17 | '185' -> SminkrumTemp, 18 | 19 | '204' -> TVrumSetWithApp, 20 | '211' -> TVrumSetPoint, 21 | '225' -> TVrumTemp, 22 | 23 | '244' -> TrapprumSetWithApp, 24 | '251' -> TrapprumSetPoint, 25 | '265' -> TrapprumTemp, 26 | 27 | '284' -> GarderobSetWithApp, 28 | '291' -> GarderobSetPoint, 29 | '305' -> GarderobTemp 30 | ) 31 | 32 | val HashMap alarmItemMap = newHashMap( 33 | '28' -> TempAlarm 34 | ) 35 | 36 | rule "Read Uponor values" 37 | when 38 | Time cron "0 0/1 * * * ?" 39 | then 40 | val url = "http://192.168.0.117/api"; 41 | val contenttype = "application/json"; 42 | 43 | var POSTrequest = '{"jsonrpc":"2.0", "id":8, "method":"read", "params":{ "objects":[%s]}}' 44 | val itemQuery = '{"id":"%s","properties":{"85":{}}}' 45 | val itemQueryList = newArrayList() 46 | val idSet = itemMap.keySet 47 | idSet.forEach[ key | itemQueryList.add(String.format(itemQuery, key)) ] 48 | POSTrequest = String.format(POSTrequest, itemQueryList.join(',')) 49 | var json = null; 50 | var count = 0; 51 | try { 52 | json = sendHttpPostRequest(url, contenttype, POSTrequest); 53 | count = Integer::parseInt(transform("JSONPATH", "$.result.objects.length()", json)); 54 | } 55 | catch(Throwable e) { 56 | logWarn("Upponor", "An error occured whuile reading the values from the Uponor gateway. " + e.getMessage()) 57 | return; 58 | } 59 | 60 | for(var i = 0; i < count; i++) { 61 | //logWarn("Banan:", transform("JSONPATH", "$.result.objects[" + i + "]", json) ); 62 | 63 | val id = transform("JSONPATH", "$.result.objects[" +i+ "].id", json) 64 | val value = transform("JSONPATH", "$.result.objects[" +i+ "].properties.85.value", json) 65 | val item = itemMap.get(id); 66 | if(item instanceof Number) { 67 | item.postUpdate(Float::parseFloat(value)) 68 | } 69 | else if(item instanceof SwitchItem) { 70 | if (value == '0') {item.postUpdate(OFF)} 71 | else { 72 | item.postUpdate(ON)} 73 | } 74 | else { 75 | item.postUpdate(value) 76 | } 77 | } 78 | end 79 | 80 | rule "Read Uponor alarms" 81 | when 82 | Time cron "0 0/1 * * * ?" 83 | then 84 | val url = "http://192.168.0.117/api"; 85 | val contenttype = "application/json"; 86 | 87 | var POSTrequest = '{"jsonrpc":"2.0", "id":8, "method":"read", "params":{ "objects":[%s]}}' 88 | val itemQuery = '{"id":"%s","properties":{"77":{}, "662":{}}}' 89 | val itemQueryList = newArrayList() 90 | val idSet = alarmItemMap.keySet 91 | idSet.forEach[ key | itemQueryList.add(String.format(itemQuery, key)) ] 92 | POSTrequest = String.format(POSTrequest, itemQueryList.join(',')) 93 | 94 | val json = sendHttpPostRequest(url, contenttype, POSTrequest); 95 | val count = Integer::parseInt(transform("JSONPATH", "$.result.objects.length()", json)); 96 | 97 | for(var i = 0; i < count; i++) { 98 | 99 | val id = transform("JSONPATH", "$.result.objects[" +i+ "].id", json) 100 | val state = transform("JSONPATH", "$.result.objects[" +i+ "].properties.662.value", json) 101 | val item = alarmItemMap.get(id); 102 | if (state.equals("0")){ 103 | item.postUpdate('OK') 104 | } 105 | else { 106 | item.postUpdate('Triggered') 107 | } 108 | } 109 | end 110 | 111 | rule "Enable Uponor set temperature" 112 | when 113 | Member of setWithApp received command 114 | then 115 | val url = "http://192.168.0.117/api"; 116 | val contenttype = "application/json"; 117 | var POSTrequest = '{"jsonrpc":"2.0", "id":9, "method":"write", "params":{ "objects":[%s]}}' 118 | val itemQuery = '{"id":"%s","properties":{"85":{"value":%s:}}}' 119 | 120 | for(e:itemMap.entrySet) { 121 | if(e.value.equals(triggeringItem)) { 122 | var state = 1; 123 | if(triggeringItem.state == OFF) { 124 | state = 0; 125 | } 126 | POSTrequest = String.format(POSTrequest, String.format(itemQuery, e.key, state)); 127 | val json = sendHttpPostRequest(url, contenttype, POSTrequest); 128 | } 129 | } 130 | 131 | end 132 | 133 | rule "Set Uponor target temperature" 134 | when 135 | Member of tempSetting received command 136 | then 137 | val url = "http://192.168.0.117/api"; 138 | val contenttype = "application/json"; 139 | var POSTrequest = '{"jsonrpc":"2.0", "id":9, "method":"write", "params":{ "objects":[%s]}}' 140 | val itemQuery = '{"id":"%s","properties":{"85":{"value":%s:}}}' 141 | 142 | for(e:itemMap.entrySet) { 143 | if(e.value.equals(triggeringItem)) { 144 | POSTrequest = String.format(POSTrequest, String.format(itemQuery, e.key, triggeringItem.state)); 145 | val json = sendHttpPostRequest(url, contenttype, POSTrequest); 146 | } 147 | } 148 | 149 | end 150 | 151 | 152 | I have gotten the id mappings from the javascript file but I don’t know what all of then does yet: 153 | 154 | System Value mappings: 155 | 156 | 20 = "module_id" 157 | 21 = "cooling_available" 158 | 22 = "holiday_mode" 159 | 23 = "forced_eco_mode" //Home/Away 160 | 24 = "hc_mode" 161 | 25 = "hc_masterslave" 162 | 26 = "ts_sv_version" 163 | 27 = "holiday_setpoint" //Note that the setpoint for the rooms doesn’t change when holiday mode is enabled. 164 | 28 = "average_temp_low" //No value? 165 | 29 = "low_temp_alarm_limit" 166 | 30 = "low_temp_alarm_hysteresis" 167 | 31 = "remote_access_alarm" //No value? 168 | 32 = "device_lost_alarm" //No value? 169 | 33 = "no_comm_controller1" 170 | 34 = "no_comm_controller2" 171 | 35 = "no_comm_controller3" 172 | 36 = "no_comm_controller4" 173 | 37 = "average_room_temperature" 174 | 38 = "controller_presence" 175 | 39 = "allow_hc_mode_change" 176 | 40 = "hc_master_type" 177 | 178 | 179 | Module Value mappings: 180 | (x = module number) 0 for me… What is module? The X165? 181 | 182 | x*500 + 60 = "output_module" 183 | x*500 + 61 = "rh_deadzone" 184 | x*500 + 62 = "controller_sv_version" 185 | x*500 + 63 = "thermostat_presence" 186 | x*500 + 64 = "supply_high_alarm" 187 | x*500 + 65 = "supply_low_alarm" 188 | x*500 + 66 = "average_room_temperature_NO" 189 | x*500 + 67 = "measured_outdoor_temperature" 190 | x*500 + 68 = "supply_temp" 191 | x*500 + 69 = "dehumidifier_status" 192 | x*500 + 70 = "outdoor_sensor_presence" 193 | 194 | Zone property mappings: 195 | x = module number 196 | y = room number (0-5 for me) 197 | 198 | x*500 + y*40 + 80 = "eco_profile_active_cf" //Read only? Seem to be set by home/Away. 199 | x*500 + y*40 + 81 = "dehumidifier_control_activation" 200 | x*500 + y*40 + 82 = "rh_control_activation" 201 | x*500 + y*40 + 83 = "eco_profile_number" 202 | x*500 + y*40 + 84 = "setpoint_write_enable" //Use room thermostat (0) or app (1) 203 | x*500 + y*40 + 85 = "cooling_allowed" 204 | x*500 + y*40 + 86 = "rh_setpoint" 205 | x*500 + y*40 + 87 = "min_setpoint" //Min value on thermostat 206 | x*500 + y*40 + 88 = "max_setpoint" //Max value on thermostat 207 | x*500 + y*40 + 89 = "min_floor_temp" 208 | x*500 + y*40 + 90 = "max_floor_temp" 209 | x*500 + y*40 + 91 = "room_setpoint" //Desired temperature in room 210 | x*500 + y*40 + 92 = "eco_offset" // 211 | x*500 + y*40 + 93 = "eco_profile_active" //Read only? Seem to be set by home/Away. 212 | x*500 + y*40 + 94 = "home_away_mode_status" //Read only? I can’t get this to change at all… 213 | x*500 + y*40 + 95 = "room_in_demand" 214 | x*500 + y*40 + 96 = "rh_limit_reached" 215 | x*500 + y*40 + 97 = "floor_limit_status" 216 | x*500 + y*40 + 98 = "technical_alarm" 217 | x*500 + y*40 + 99 = "tamper_indication" 218 | x*500 + y*40 + 100 = "rf_alarm" 219 | x*500 + y*40 + 101 = "battery_alarm" 220 | x*500 + y*40 + 102 = "rh_sensor" 221 | x*500 + y*40 + 103 = "thermostat_type" 222 | x*500 + y*40 + 104 = "regulation_mode" 223 | x*500 + y*40 + 105 = "room_temperature" //Actual temperature 224 | x*500 + y*40 + 106 = "room_temperature_ext" 225 | x*500 + y*40 + 107 = "rh_value" 226 | x*500 + y*40 + 108 = "ch_linked_to_th" 227 | x*500 + y*40 + 109 = "room_name" // This could be usefull… 228 | x*500 + y*40 + 110 = "utilization_factor_24h" 229 | x*500 + y*40 + 111 = "utilization_factor_7d" 230 | x*500 + y*40 + 112 = "reg_mode" 231 | x*500 + y*40 + 113 = "channel_average" 232 | x*500 + y*40 + 114 = "radiator_heating" 233 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Uponor Uhome integration", 3 | "render_readme": true, 4 | "domains": ["sensor", "climate"] 5 | } --------------------------------------------------------------------------------