├── .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 |
4 |
5 | [](https://github.com/hacs/integration)
6 | [](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 | [](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 | }
--------------------------------------------------------------------------------