├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.yml └── workflows │ ├── hassfest.yaml │ └── validate-with-hacs.yml ├── hacs.json ├── custom_components └── irsap_ha │ ├── device.py │ ├── const.py │ ├── setup.py │ ├── manifest.json │ ├── device_manager.py │ ├── __init__.py │ ├── config_flow.py │ ├── sensor.py │ ├── light.py │ └── climate.py └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "IRSAP Integration", 3 | "render_readme": true, 4 | "homeassistant": "2024.9.3" 5 | } 6 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/device.py: -------------------------------------------------------------------------------- 1 | class RadiatorDevice: 2 | def __init__(self, radiator, token, envID): 3 | self.radiator = radiator 4 | self.token = token 5 | self.envID = envID 6 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.github/workflows/validate-with-hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the irsap_ha integration.""" 2 | 3 | DOMAIN = "irsap_ha" 4 | 5 | # Config Entry Keys 6 | CONF_USERNAME = "username" 7 | CONF_PASSWORD = "password" 8 | 9 | # Default values 10 | DEFAULT_NAME = "Radiator" 11 | 12 | # Cognito Configuration 13 | USER_POOL_ID = "eu-west-1_qU4ok6EGG" 14 | CLIENT_ID = "4eg8veup8n831ebokk4ii5uasf" 15 | REGION = "eu-west-1" 16 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/setup.py: -------------------------------------------------------------------------------- 1 | from .device_manager import device_manager 2 | from .climate import async_setup_entry as setup_climate 3 | from .sensor import async_setup_entry as setup_sensor 4 | 5 | 6 | async def async_setup(hass, config): 7 | """Imposta le piattaforme clima e sensore.""" 8 | 9 | # Non passare async_add_entities qui, viene gestito in async_setup_entry 10 | return True 11 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "irsap_ha", 3 | "name": "IRSAP NOW Integration", 4 | "codeowners": ["@hexCut", "@valerix85", "@Sim0cYz"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/hexCut/irsap-ha/wiki", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/hexCut/irsap-ha/issues", 10 | "requirements": ["warrant"], 11 | "version": "1.5" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/device_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | _LOGGER = logging.getLogger(__name__) 4 | 5 | 6 | class DeviceManager: 7 | def __init__(self): 8 | self.devices = [] 9 | 10 | def add_device(self, device): 11 | self.devices.append(device) 12 | _LOGGER.debug(f"Device added: {device.radiator['serial']}") 13 | 14 | def get_devices(self): 15 | return self.devices 16 | 17 | 18 | # Singleton 19 | device_manager = DeviceManager() 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[REQ] " 5 | labels: enhancement 6 | assignees: valerix85 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /custom_components/irsap_ha/__init__.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers import device_registry as dr 2 | from .const import DOMAIN 3 | 4 | import logging 5 | from homeassistant import config_entries 6 | from homeassistant.core import HomeAssistant 7 | from .setup import async_setup as setup_component 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | async def async_setup(hass: HomeAssistant, config: dict): 13 | return await setup_component(hass, config) 14 | 15 | 16 | async def async_setup_entry(hass, config_entry): 17 | """Imposta il custom component""" 18 | hass.data.setdefault(DOMAIN, {}) 19 | hass.data[DOMAIN][config_entry.entry_id] = { 20 | "token": config_entry.data["token"], 21 | "envID": config_entry.data["envID"], 22 | } 23 | 24 | # Carica prima 'climate' e poi 'sensor' 25 | await hass.config_entries.async_forward_entry_setups(config_entry, ["climate"]) 26 | await hass.config_entries.async_forward_entry_setups(config_entry, ["sensor"]) 27 | await hass.config_entries.async_forward_entry_setups(config_entry, ["light"]) 28 | 29 | return True 30 | 31 | 32 | async def async_unload_entry(hass, config_entry): 33 | """Scarica le entità del custom component""" 34 | unload_ok = await hass.config_entries.async_unload_platforms( 35 | config_entry, ["climate", "sensor", "light"] 36 | ) 37 | if unload_ok: 38 | hass.data[DOMAIN].pop(config_entry.entry_id) 39 | return unload_ok 40 | 41 | 42 | async def async_remove_config_entry_device( 43 | hass: HomeAssistant, entry: config_entries.ConfigEntry, device_id: str 44 | ) -> None: 45 | """Remove a device from the config entry.""" 46 | device_registry = dr.async_get(hass) 47 | 48 | if device_registry.async_get_device({(dr.CONNECTION_NETWORK_MAC, device_id)}): 49 | device_registry.async_remove_device(device_id) 50 | _LOGGER.debug(f"Removed device {device_id} from config entry {entry.entry_id}") 51 | else: 52 | _LOGGER.warning( 53 | f"Device {device_id} not found in config entry {entry.entry_id}" 54 | ) 55 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Report bug 2 | description: Report an issue with IRSAP Integration 3 | labels: ["bug"] 4 | assignees: valerix85 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report. Before you create this new issue, [read the docs](https://github.com/hexCut/irsap-ha/). 10 | - type: checkboxes 11 | id: docsread 12 | attributes: 13 | label: Did you read the docs? 14 | description: You are required to read [the docs]https://github.com/hexCut/irsap-ha/) and confirm your question wasn't anwered there. 15 | options: 16 | - label: I read the docs and my question is not answered there. 17 | required: true 18 | - type: textarea 19 | validations: 20 | required: true 21 | attributes: 22 | label: What happened? 23 | description: | 24 | Please give a clear and concise description of the issue you are experiencing here, 25 | to communicate to the maintainers. Tell us what you were trying to do, what happened and what you expected. 26 | - type: textarea 27 | validations: 28 | required: true 29 | attributes: 30 | label: How to reproduce 31 | description: | 32 | Describe the least amount of steps possible to reproduce your error 33 | - type: textarea 34 | id: logs 35 | attributes: 36 | label: Relevant log output 37 | description: | 38 | Please copy and paste any relevant log output. Use the field below. 39 | render: shell 40 | - type: input 41 | id: version 42 | validations: 43 | required: true 44 | attributes: 45 | label: Which version are you running? 46 | description: Version 47 | - type: checkboxes 48 | id: diagnostics 49 | attributes: 50 | label: Diagnostics file 51 | description: You are *required* to attach a diagnostics file. Issues that do not have a diagnostic file will be closed immediately. To download a diagnostics file, in Home Assistant, go to Settings>Devices&Services>Integrations>IRSAP NOW Integration, or use [this link](https://my.home-assistant.io/redirect/integration/?domain=irsap_ha). Click the 'three vertical dots' menu and select 'download diagnostics'. 52 | options: 53 | - label: I have attached a diagnostics file 54 | required: true 55 | - type: textarea 56 | attributes: 57 | label: Additional information 58 | description: | 59 | If you have any additional information for us. Use the field below. 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant - Custom Components IRSAP Integration 2 | 3 | [hacs]: https://github.com/hacs/integration 4 | [githubrelease]: https://github.com/hexCut/irsap-ha/releases 5 | [maintenancebadge]: https://img.shields.io/badge/Maintained%3F-Yes-brightgreen.svg 6 | [maintenance]: https://github.com/hexCut/irsap-ha/graphs/commit-activity 7 | [github issues]: https://github.com/hexCut/irsap-ha/issues 8 | 9 | [![hacs][hacsbadge]][hacs] 10 | 11 | [![GitHub latest release]][githubrelease] ![GitHub Release Date] [![Maintenancebadge]][maintenance] [![GitHub issuesbadge]][github issues] 12 | 13 | --- 14 | 15 | ## Information 16 | 17 | This custom integration for [Home Assistant](https://www.home-assistant.io) allows monitoring and controlling IRSAP radiators via AWS Cognito authentication and API requests. Radiator data is retrieved from the IRSAP API endpoint and displayed in Home Assistant as a climate entity 18 | 19 | 20 | ## Features 21 | 22 | - **Temperature Monitoring**: Sensors display the current temperature of the installed radiators. 23 | - **On/Off Control**: Switches allow you to turn radiators on or off directly from Home Assistant. 24 | - **Real-time Updates**: Radiator data is updated periodically through API requests to IRSAP. 25 | 26 | ### Data Retrieved from the IRSAP API 27 | 28 | The integration connects to the IRSAP API endpoint and retrieves a JSON payload containing various information about the radiators, including: 29 | 30 | - **Current Temperature**: The current temperature of each radiator is displayed (normalized for Home Assistant). 31 | - **HVAC Mode**: Switches control the on/off state of each radiator. 32 | 33 | ## Installation 34 | 35 | Easiest install is via [HACS](https://hacs.xyz/): 36 | 37 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=hexCut&repository=irsap-ha&category=integration) 38 | 39 | `HACS -> Integrations -> Explore & Add Repositories -> IRSAP NOW Integration` 40 | 41 | OR 42 | 43 | 1. Click on HACS in the Home Assistant menu 44 | 2. Click on Add Custom Repositories 45 | 3. Add the following GitHub repo 46 | ``` 47 | https://github.com/hexCut/irsap-ha 48 | ``` 49 | 4. Click on `Integrations` 50 | 5. Click the `EXPLORE & ADD REPOSITORIES` button 51 | 6. Search for `IRSAP NOW Integration` 52 | 7. Click the `INSTALL THIS REPOSITORY IN HACS` button 53 | 8. Restart Home Assistant 54 | 55 | ## Configuration 56 | 57 | ### Config flow 58 | 59 | To configure this integration go to: `Configurations` -> `Integrations` -> `ADD INTEGRATIONS` button, search for `IRSAP NOW Integration` and configure the component. 60 | 61 | You can also use following [My Home Assistant](http://my.home-assistant.io/) link 62 | 63 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=irsap-ha) 64 | 65 | ### Setup 66 | 67 | Now the integration is added to HACS and available in the normal HA integration installation 68 | 69 | 1. In the HomeAssistant left menu, click `Configuration` 70 | 2. Click `Integrations` 71 | 3. Click `ADD INTEGRATION` 72 | 4. Type `IRSAP NOW Integration` and select it 73 | 5. Enter the details: 74 | 1. **Username**: Your username to login via IRSAP Now App 75 | 2. **Password**: Your password to login via IRSAP Now App 76 | 77 | ## Contributions are welcome 78 | 79 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/rsplab) 80 | 81 | --- 82 | 83 | ## Trademark Legal Notices 84 | 85 | All product names, trademarks and registered trademarks in the images in this repository, are property of their respective owners. 86 | All images in this repository are used by the author for identification purposes only. 87 | The use of these names, trademarks and brands appearing in these image files, do not imply endorsement. 88 | 89 | [hacs]: https://github.com/hacs/integration 90 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg 91 | [github latest release]: https://img.shields.io/github/v/release/hexCut/irsap-ha 92 | [githubrelease]: https://github.com/hexCut/irsap-ha/releases 93 | [github release date]: https://img.shields.io/github/release-date/hexCut/irsap-ha 94 | [maintenancebadge]: https://img.shields.io/badge/Maintained%3F-Yes-brightgreen.svg 95 | [maintenance]: https://github.com/hexCut/irsap-ha/graphs/commit-activity 96 | [github issuesbadge]: https://img.shields.io/github/issues/irsap-ha/issues 97 | [github issues]: https://github.com/hexCut/irsap-ha/issues 98 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import aiohttp 3 | from homeassistant import config_entries 4 | from homeassistant.core import HomeAssistant, callback 5 | from homeassistant.helpers import entity_registry as er 6 | from homeassistant.data_entry_flow import FlowResult 7 | import voluptuous as vol 8 | from .const import DOMAIN, USER_POOL_ID, CLIENT_ID, REGION 9 | from warrant import Cognito 10 | import asyncio 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 16 | "Handle a config flow for radiators integration." 17 | 18 | VERSION = 1 19 | 20 | async def async_step_user(self, user_input=None) -> FlowResult: 21 | "Handle the initial step." 22 | 23 | # Controlla se esiste già un'istanza dell'integrazione 24 | await self.async_set_unique_id(DOMAIN) 25 | existing_entry = self._async_current_entries() 26 | 27 | if existing_entry: 28 | # Se esiste già un'istanza, abortisci il flusso con un messaggio personalizzato 29 | return self.async_abort(reason="Only one istance is allowed") 30 | 31 | if user_input is None: 32 | return self.async_show_form( 33 | step_id="user", 34 | data_schema=vol.Schema( 35 | { 36 | vol.Required("username"): str, 37 | vol.Required("password"): str, 38 | } 39 | ), 40 | ) 41 | 42 | # Estrazione delle credenziali dall'input dell'utente 43 | username = user_input["username"] 44 | password = user_input["password"] 45 | 46 | # Call the function directly since login_with_srp already handles executor wrapping 47 | token = await login_with_srp(self.hass, username, password) 48 | 49 | if token is None: 50 | _LOGGER.error("Login failed, invalid credentials.") 51 | return self.async_show_form( 52 | step_id="user", 53 | data_schema=vol.Schema( 54 | { 55 | vol.Required("username"): str, 56 | vol.Required("password"): str, 57 | } 58 | ), 59 | errors={"base": "invalid_credentials"}, 60 | ) 61 | 62 | envID = await self.async_get_envID(username, password, token) 63 | 64 | if envID is None: 65 | _LOGGER.error("Failed to obtain envID.") 66 | return self.async_show_form( 67 | step_id="user", 68 | data_schema=vol.Schema( 69 | { 70 | vol.Required("username"): str, 71 | vol.Required("password"): str, 72 | } 73 | ), 74 | errors={"base": "envid_failed"}, 75 | ) 76 | 77 | # Se tutto è andato bene, salviamo l'entry 78 | return self.async_create_entry( 79 | title=username, 80 | data={ 81 | "username": username, 82 | "password": password, 83 | "token": token, 84 | "envID": envID, 85 | }, 86 | ) 87 | 88 | @staticmethod 89 | @callback 90 | def async_get_options_flow(config_entry): 91 | return RadiatorsIntegrationOptionsFlow(config_entry) 92 | 93 | async def async_get_envID(self, username, password, token): 94 | """Asynchronous method to get envID.""" 95 | return await envid_with_srp(username, password, token) 96 | 97 | 98 | class RadiatorsIntegrationOptionsFlow(config_entries.OptionsFlow): 99 | "Handle the options flow for the integration." 100 | 101 | def __init__(self, config_entry): 102 | "Initialize the options flow." 103 | self.config_entry = config_entry 104 | 105 | async def async_step_init(self, user_input=None): 106 | "Manage the options." 107 | if user_input is not None: 108 | # Update the config entry options with new values 109 | return self.async_create_entry(title="", data=user_input) 110 | 111 | # Define the schema for the options form 112 | options_schema = vol.Schema( 113 | { 114 | vol.Required( 115 | "username", default=self.config_entry.data.get("username") 116 | ): str, 117 | vol.Required( 118 | "password", default=self.config_entry.data.get("password") 119 | ): str, 120 | } 121 | ) 122 | 123 | return self.async_show_form(step_id="init", data_schema=options_schema) 124 | 125 | async def _update_options(self, user_input): 126 | "Update config entry options and reload entities." 127 | # Aggiorna le opzioni con le nuove credenziali 128 | self.hass.config_entries.async_update_entry(self.config_entry, data=user_input) 129 | 130 | # Ottieni il registro delle entità 131 | entity_registry = er.async_get(self.hass) 132 | 133 | # Elimina le entità esistenti create dall'integrazione 134 | entities = er.async_entries_for_config_entry( 135 | entity_registry, self.config_entry.entry_id 136 | ) 137 | for entity in entities: 138 | entity_registry.async_remove(entity.entity_id) 139 | 140 | return self.async_create_entry(title="", data=user_input) 141 | 142 | 143 | async def login_with_srp(hass, username, password): 144 | "Log in and obtain the access token using Warrant." 145 | return await hass.async_add_executor_job(_sync_login_with_srp, username, password) 146 | 147 | 148 | def _sync_login_with_srp(username, password): 149 | """Synchronous function to log in using Warrant.""" 150 | try: 151 | u = Cognito(USER_POOL_ID, CLIENT_ID, username=username, user_pool_region=REGION) 152 | u.authenticate(password=password) 153 | _LOGGER.debug(f"Access Token: {u.access_token}") 154 | return u.access_token 155 | except Exception as e: 156 | _LOGGER.error(f"Error during login: {e}") 157 | return None 158 | 159 | 160 | async def envid_with_srp(username, password, token): 161 | """Login and obtain the envID using Warrant.""" 162 | async with aiohttp.ClientSession() as session: 163 | url = "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 164 | headers = { 165 | "Authorization": f"Bearer {token}", 166 | "Content-Type": "application/json", 167 | } 168 | graphql_query = { 169 | "operationName": "ListEnvironments", 170 | "variables": {}, 171 | "query": "query ListEnvironments {\n listEnvironments {\n environments {\n envId\n envName\n userRole\n __typename\n }\n __typename\n }\n}\n", 172 | } 173 | 174 | try: 175 | async with session.post( 176 | url, headers=headers, json=graphql_query 177 | ) as response: 178 | if response.status != 200: 179 | error_msg = await response.text() 180 | _LOGGER.error(f"API request error: {response.status} - {error_msg}") 181 | return None 182 | 183 | data = await response.json() 184 | environments = ( 185 | data.get("data", {}) 186 | .get("listEnvironments", {}) 187 | .get("environments", []) 188 | ) 189 | if not environments: 190 | _LOGGER.error("No environments found in the API response") 191 | return None 192 | 193 | envId = environments[0].get("envId") 194 | if envId: 195 | _LOGGER.debug(f"envId retrieved from API: {envId}") 196 | return envId 197 | else: 198 | _LOGGER.error("envId missing in the API response") 199 | return None 200 | 201 | except Exception as e: 202 | _LOGGER.error(f"Error during API call: {e}") 203 | return None 204 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/sensor.py: -------------------------------------------------------------------------------- 1 | from .const import DOMAIN, USER_POOL_ID, CLIENT_ID, REGION 2 | import logging 3 | from homeassistant.components.sensor import SensorEntity, datetime 4 | import aiohttp 5 | import json 6 | from warrant import Cognito 7 | import re 8 | from .device_manager import device_manager 9 | import pytz 10 | from homeassistant.util import dt as dt_util 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass, config_entry, async_add_entities): 16 | from .climate import RadiatorClimate # Importa RadiatorClimate qui 17 | 18 | envID = config_entry.data["envID"] 19 | username = config_entry.data["username"] 20 | password = config_entry.data["password"] 21 | 22 | token = await hass.async_add_executor_job(login_with_srp, username, password) 23 | 24 | if token and envID: 25 | _LOGGER.debug("Token and envID successfully obtained. Retrieving sensors.") 26 | 27 | hass.data.setdefault(DOMAIN, {}) 28 | hass.data[DOMAIN]["token"] = token 29 | hass.data[DOMAIN]["envID"] = envID 30 | hass.data[DOMAIN]["username"] = username 31 | hass.data[DOMAIN]["password"] = password 32 | 33 | devices = device_manager.get_devices() # Ottieni i dispositivi dal manager 34 | _LOGGER.debug( 35 | f"Devices found: {[device.radiator['serial'] for device in devices]}" 36 | ) 37 | 38 | if not devices: 39 | _LOGGER.error( 40 | "No devices found. Please ensure that climate entities are set up correctly." 41 | ) 42 | return 43 | 44 | sensors = await get_sensor_data(token, envID) 45 | sensor_entities = [] 46 | 47 | for r in sensors: 48 | # Trova il dispositivo associato al sensore 49 | device = next( 50 | (d for d in devices if d.radiator["serial"] == r["serial"]), None 51 | ) 52 | 53 | if device is not None: 54 | sensor_entity = RadiatorSensor( 55 | r, device, unique_id=f"{r['serial']}_ip_address" 56 | ) 57 | sensor_entities.append(sensor_entity) 58 | # Aggiungi tutti i sensori necessari per ciascun dispositivo 59 | sensor_entities.append( 60 | LastUpdateSensor(r, device, unique_id=f"{r['serial']}_last_update") 61 | ) 62 | sensor_entities.append( 63 | WifiSignalSensor(r, device, unique_id=f"{r['serial']}_wifi_signal") 64 | ) 65 | sensor_entities.append( 66 | PiloteEnableSensor( 67 | r, device, unique_id=f"{r['serial']}_pilote_enable" 68 | ) 69 | ) 70 | sensor_entities.append( 71 | PiloteStatusSensor( 72 | r, device, unique_id=f"{r['serial']}_pilote_status" 73 | ) 74 | ) 75 | sensor_entities.append( 76 | StandbySensor(r, device, unique_id=f"{r['serial']}_standby") 77 | ) 78 | sensor_entities.append( 79 | OpenWindowEnabledSensor( 80 | r, device, unique_id=f"{r['serial']}_openwindow_enabled" 81 | ) 82 | ) 83 | sensor_entities.append( 84 | OpenWindowOffsetSensor( 85 | r, device, unique_id=f"{r['serial']}_openwindow_offset" 86 | ) 87 | ) 88 | sensor_entities.append( 89 | TemperatureOffsetSensor( 90 | r, device, unique_id=f"{r['serial']}_temperature_offset" 91 | ) 92 | ) 93 | sensor_entities.append( 94 | HysteresisSensor(r, device, unique_id=f"{r['serial']}_hysteresis") 95 | ) 96 | sensor_entities.append( 97 | VocSensor(r, device, unique_id=f"{r['serial']}_voc") 98 | ) 99 | sensor_entities.append( 100 | Co2Sensor(r, device, unique_id=f"{r['serial']}_co2") 101 | ) 102 | sensor_entities.append( 103 | OpenWindowDetectedSensor( 104 | r, device, unique_id=f"{r['serial']}_openwindow_detected" 105 | ) 106 | ) 107 | sensor_entities.append( 108 | LockSensor(r, device, unique_id=f"{r['serial']}_lock") 109 | ) # Child lock sensor 110 | sensor_entities.append( 111 | AIQSensor(r, device, unique_id=f"{r['serial']}_aiq") 112 | ) 113 | else: 114 | _LOGGER.debug(f"No matching device found for sensor {r['serial']}") 115 | 116 | async_add_entities(sensor_entities, True) 117 | else: 118 | _LOGGER.error("Unable to obtain the token or envID. Check configuration.") 119 | 120 | 121 | def login_with_srp(username, password): 122 | "Log in and obtain the access token using Warrant." 123 | try: 124 | u = Cognito(USER_POOL_ID, CLIENT_ID, username=username, user_pool_region=REGION) 125 | u.authenticate(password=password) 126 | _LOGGER.debug(f"Access Token: {u.access_token}") 127 | return u.access_token 128 | except Exception as e: 129 | _LOGGER.error(f"Error during login: {e}") 130 | return None 131 | 132 | 133 | async def get_sensor_data(token, envID): 134 | "Fetch radiator data from the API." 135 | url = ( 136 | "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 137 | ) 138 | headers = { 139 | "Authorization": f"Bearer {token}", 140 | "Content-Type": "application/json", 141 | } 142 | graphql_query = { 143 | "operationName": "GetShadow", 144 | "variables": {"envId": envID}, 145 | "query": "query GetShadow($envId: ID!) {\n getShadow(envId: $envId) {\n envId\n payload\n __typename\n }\n}\n", 146 | } 147 | 148 | try: 149 | async with aiohttp.ClientSession() as session: 150 | async with session.post( 151 | url, json=graphql_query, headers=headers 152 | ) as response: 153 | if response.status == 200: 154 | data = await response.json() 155 | payload = json.loads(data["data"]["getShadow"]["payload"]) 156 | _LOGGER.debug(f"Payload retrieved from API: {payload}") 157 | 158 | return extract_device_info(payload["state"]["desired"]) 159 | else: 160 | _LOGGER.error(f"API request error: {response.status}") 161 | return [] 162 | except ValueError as e: 163 | _LOGGER.error(f"Error converting temperature: {e}") 164 | return [] 165 | except Exception as e: 166 | _LOGGER.error(f"Error during API call: {e}") 167 | return [] 168 | 169 | 170 | import re 171 | 172 | 173 | def extract_device_info( 174 | payload, 175 | nam_suffix="_NAM", 176 | tmp_suffix="_TMP", 177 | enb_suffix="_ENB", 178 | exclude_suffix="E_NAM", 179 | ): 180 | devices_info = [] 181 | 182 | # Suffixi di interesse per le chiavi 183 | suffixes = [ 184 | "_CNT", 185 | "_FWV", 186 | "_TYP", 187 | "_SLV", 188 | "_LUP", 189 | "_X_ipAddress", 190 | "_X_filPiloteEnabled", 191 | "_X_filPiloteStatus", 192 | "_X_standby", 193 | "_X_OpenWindowSensorEnabled", 194 | "_X_OpenWindowDetected", 195 | "_X_OpenWindowSensorOffTime", 196 | "_X_temperatureSensorOffset", 197 | "_X_hysteresis", 198 | "_X_vocValue", 199 | "_X_co2Value", 200 | "_X_lock", 201 | "_AIQ", 202 | ] 203 | 204 | # Liste per raccogliere le chiavi 205 | nam_keys = [] 206 | cnt_keys = [] 207 | fwv_keys = [] 208 | typ_keys = [] 209 | slv_keys = [] 210 | lup_keys = [] 211 | ip_keys = [] 212 | pilote_enb_keys = [] 213 | pilote_sta_keys = [] 214 | stand_keys = [] 215 | openwin_enab_keys = [] 216 | openwin_dect_keys = [] 217 | openwin_off_keys = [] 218 | temp_off_keys = [] 219 | hyst_keys = [] 220 | voc_keys = [] 221 | co2_keys = [] 222 | lock_keys = [] 223 | aiq_keys = [] 224 | 225 | def find_device_keys(obj): 226 | if isinstance(obj, dict): 227 | for key, value in obj.items(): 228 | # Raccogli chiavi _NAM e aggiungi i dettagli iniziali 229 | if key.endswith(nam_suffix) and not key.startswith(exclude_suffix): 230 | device_info = { 231 | "serial": value, 232 | "temperature": 0, # Default a 0 se non trovata 233 | "state": "OFF", # Default a OFF se non trovato 234 | } 235 | nam_keys.append((key, device_info)) 236 | 237 | # Controlla se la chiave finisce con uno dei suffissi 238 | if any(key.endswith(suffix) for suffix in suffixes): 239 | # Aggiungi la chiave alla lista corrispondente 240 | if key.endswith("_CNT"): 241 | cnt_keys.append((key, value)) 242 | elif key.endswith("_FWV"): 243 | fwv_keys.append((key, value)) 244 | elif key.endswith("_TYP"): 245 | typ_keys.append((key, value)) 246 | elif key.endswith("_SLV"): 247 | slv_keys.append((key, value)) 248 | elif key.endswith("_LUP"): 249 | lup_keys.append((key, value)) 250 | elif key.endswith("_X_ipAddress"): 251 | ip_keys.append((key, value)) 252 | elif key.endswith("_X_filPiloteEnabled"): 253 | pilote_enb_keys.append((key, value)) 254 | elif key.endswith("_X_filPiloteStatus"): 255 | pilote_sta_keys.append((key, value)) 256 | elif key.endswith("_X_standby"): 257 | stand_keys.append((key, value)) 258 | elif key.endswith("_X_OpenWindowSensorEnabled"): 259 | openwin_enab_keys.append((key, value)) 260 | elif key.endswith("_X_OpenWindowDetected"): 261 | openwin_dect_keys.append((key, value)) 262 | elif key.endswith("_X_OpenWindowSensorOffTime"): 263 | openwin_off_keys.append((key, value)) 264 | elif key.endswith("_X_temperatureSensorOffset"): 265 | temp_off_keys.append((key, value)) 266 | elif key.endswith("_X_hysteresis"): 267 | hyst_keys.append((key, value)) 268 | elif key.endswith("_X_vocValue"): 269 | voc_keys.append((key, value)) 270 | elif key.endswith("_X_co2Value"): 271 | co2_keys.append((key, value)) 272 | elif key.endswith("_X_lock"): 273 | lock_keys.append((key, value)) 274 | elif key.endswith("_AIQ"): 275 | aiq_keys.append((key, value)) 276 | 277 | # Ricorsione per esplorare eventuali chiavi annidate 278 | find_device_keys(value) 279 | elif isinstance(obj, list): 280 | for item in obj: 281 | find_device_keys(item) 282 | 283 | # Esegui la ricerca nel payload 284 | find_device_keys(payload) 285 | 286 | # Associa ogni _NAM ai suoi corrispondenti attributi 287 | for i, (nam_key, device_info) in enumerate(nam_keys): 288 | base_key = nam_key[: -len(nam_suffix)] 289 | corresponding_tmp_key = base_key + tmp_suffix 290 | corresponding_enb_key = base_key + enb_suffix 291 | 292 | # Trova la temperatura 293 | if corresponding_tmp_key in payload: 294 | tmp_value = payload.get(corresponding_tmp_key) 295 | device_info["temperature"] = ( 296 | float(tmp_value) / 10 if tmp_value is not None else 0 297 | ) 298 | 299 | # Trova lo stato (ON/OFF) 300 | if corresponding_enb_key in payload: 301 | enb_value = payload.get(corresponding_enb_key) 302 | device_info["state"] = "HEAT" if enb_value == 1 else "OFF" 303 | 304 | # Associa altri dati (es. MAC, firmware, IP, etc.) 305 | if i < len(cnt_keys): 306 | device_info["mac"] = cnt_keys[i][1] 307 | if i < len(fwv_keys): 308 | device_info["firmware"] = fwv_keys[i][1] 309 | if i < len(typ_keys): 310 | device_info["model"] = typ_keys[i][1] 311 | if i < len(slv_keys): 312 | device_info["wifi_signal"] = slv_keys[i][1] 313 | if i < len(lup_keys): 314 | device_info["last_update"] = lup_keys[i][1] 315 | if i < len(ip_keys): 316 | device_info["ip_address"] = ip_keys[i][1] 317 | if i < len(pilote_enb_keys): 318 | device_info["pilote_enable"] = pilote_enb_keys[i][1] 319 | if i < len(pilote_sta_keys): 320 | device_info["pilote_status"] = pilote_sta_keys[i][1] 321 | if i < len(stand_keys): 322 | device_info["standby"] = stand_keys[i][1] 323 | if i < len(openwin_enab_keys): 324 | device_info["open_window_enabled"] = openwin_enab_keys[i][1] 325 | if i < len(openwin_dect_keys): 326 | device_info["openwindow_detected"] = openwin_dect_keys[i][1] 327 | if i < len(openwin_off_keys): 328 | device_info["openwindow_offset"] = openwin_off_keys[i][1] 329 | if i < len(temp_off_keys): 330 | device_info["temperature_offset"] = temp_off_keys[i][1] 331 | if i < len(hyst_keys): 332 | device_info["hysteresis"] = hyst_keys[i][1] 333 | if i < len(voc_keys): 334 | device_info["voc"] = voc_keys[i][1] 335 | if i < len(co2_keys): 336 | device_info["co2"] = co2_keys[i][1] 337 | if i < len(lock_keys): 338 | device_info["lock"] = lock_keys[i][1] 339 | if i < len(aiq_keys): 340 | device_info["aiq"] = aiq_keys[i][1] 341 | 342 | devices_info.append(device_info) 343 | 344 | return devices_info 345 | 346 | 347 | class RadiatorSensor(SensorEntity): 348 | def __init__(self, radiator, device, unique_id): 349 | self._radiator = radiator 350 | self._device = device # Store device reference 351 | self._attr_name = f"{radiator['serial']} IP Address" 352 | self._attr_unique_id = unique_id 353 | self._attr_icon = "mdi:ip" 354 | self._radiator_serial = radiator["serial"] 355 | self._model = radiator.get("model", "Modello Sconosciuto") 356 | self._attr_native_value = radiator.get("ip_address", "IP non disponibile") 357 | self._sw_version = radiator.get("firmware") 358 | 359 | @property 360 | def native_value(self): 361 | return self._attr_native_value 362 | 363 | @property 364 | def unique_id(self): 365 | return self._attr_unique_id 366 | 367 | @property 368 | def device_info(self): 369 | """Associa il sensore al dispositivo climate con il seriale corrispondente.""" 370 | return { 371 | "identifiers": { 372 | (DOMAIN, self._radiator["serial"]) 373 | }, # Use device serial number 374 | "name": f"{self._radiator['serial']}", 375 | "model": self._radiator.get("model", "Unknown Model"), 376 | "manufacturer": "IRSAP", 377 | "sw_version": self._radiator.get("firmware", "Unknown Firmware"), 378 | } 379 | 380 | 381 | class BaseRadiatorSensor(SensorEntity): 382 | """Base class for radiator sensors.""" 383 | 384 | def __init__( 385 | self, radiator, device, unique_id, attr_name, icon, data_key, formatter=None 386 | ): 387 | self._radiator = radiator 388 | self._device = device 389 | self._attr_name = f"{radiator['serial']} {attr_name}" 390 | self._attr_unique_id = unique_id 391 | self._attr_icon = icon 392 | self._attr_native_value = None 393 | self._data_key = data_key 394 | self._formatter = formatter 395 | 396 | @property 397 | def native_value(self): 398 | """Retrieve and format the value for the sensor.""" 399 | raw_value = self._radiator.get(self._data_key) 400 | if raw_value is None: 401 | return "N/A" 402 | if self._formatter: 403 | return self._formatter(raw_value) 404 | return raw_value 405 | 406 | @property 407 | def unique_id(self): 408 | return self._attr_unique_id 409 | 410 | @property 411 | def device_info(self): 412 | return { 413 | "identifiers": {(DOMAIN, self._radiator["serial"])}, 414 | "name": f"{self._radiator['serial']}", 415 | "model": self._radiator.get("model", "Unknown Model"), 416 | "manufacturer": "IRSAP", 417 | "sw_version": self._radiator.get("firmware", "Unknown Firmware"), 418 | } 419 | 420 | async def async_update(self): 421 | """Update the sensor value periodically.""" 422 | self._attr_native_value = self.native_value 423 | _LOGGER.debug(f"Updated {self._attr_name} to {self._attr_native_value}") 424 | 425 | 426 | class WifiSignalSensor(BaseRadiatorSensor): 427 | def __init__(self, radiator, device, unique_id): 428 | super().__init__( 429 | radiator, 430 | device, 431 | unique_id, 432 | "WiFi Signal Strength", 433 | "mdi:wifi", 434 | "wifi_signal", 435 | ) 436 | 437 | @property 438 | def native_value(self): 439 | """Return the WiFi signal strength in dBm.""" 440 | return self._radiator.get("wifi_signal", "N/A") 441 | 442 | 443 | class PiloteEnableSensor(BaseRadiatorSensor): 444 | def __init__(self, radiator, device, unique_id): 445 | super().__init__( 446 | radiator, device, unique_id, "Pilote Enable", "mdi:power", "pilote_enable" 447 | ) 448 | 449 | @property 450 | def native_value(self): 451 | """Return 'Enabled' if pilote feature is active (1), otherwise 'Disabled' (0).""" 452 | status = self._radiator.get("pilote_enable", None) 453 | if status == 1: 454 | return "Enabled" 455 | elif status == 0: 456 | return "Disabled" 457 | return "Unknown" # Default if status is not available 458 | 459 | 460 | class PiloteStatusSensor(BaseRadiatorSensor): 461 | def __init__(self, radiator, device, unique_id): 462 | super().__init__( 463 | radiator, 464 | device, 465 | unique_id, 466 | "Pilote Status", 467 | "mdi:check-circle", 468 | "pilote_status", 469 | ) 470 | 471 | @property 472 | def native_value(self): 473 | """Return 'Active' if pilote is currently active (1), otherwise 'Inactive' (0).""" 474 | status = self._radiator.get("pilote_status", None) 475 | if status == 1: 476 | return "Active" 477 | elif status == 0: 478 | return "Inactive" 479 | return "Unknown" # Default if status is not available 480 | 481 | 482 | class StandbySensor(BaseRadiatorSensor): 483 | def __init__(self, radiator, device, unique_id): 484 | super().__init__(radiator, device, unique_id, "Standby", "mdi:sleep", "standby") 485 | 486 | @property 487 | def native_value(self): 488 | """Return 'Yes' if standby feature is active (1), otherwise 'No' (0).""" 489 | status = self._radiator.get("standby", None) 490 | if status == 1: 491 | return "Yes" 492 | elif status == 0: 493 | return "No" 494 | return "Unknown" # Default if status is not available 495 | 496 | 497 | class OpenWindowEnabledSensor(BaseRadiatorSensor): 498 | def __init__(self, radiator, device, unique_id): 499 | super().__init__( 500 | radiator, 501 | device, 502 | unique_id, 503 | "Open Window Enabled", 504 | "mdi:window-open", 505 | "open_window_enabled", 506 | ) 507 | 508 | @property 509 | def native_value(self): 510 | """Return 'Enabled' if open window feature is active (1), otherwise 'Disabled' (0).""" 511 | status = self._radiator.get("open_window_enabled", None) 512 | if status == 1: 513 | return "Enabled" 514 | elif status == 0: 515 | return "Disabled" 516 | return "Unknown" # Default if status is not available 517 | 518 | 519 | class OpenWindowOffsetSensor(BaseRadiatorSensor): 520 | def __init__(self, radiator, device, unique_id): 521 | super().__init__( 522 | radiator, 523 | device, 524 | unique_id, 525 | "Open Window Offset", 526 | "mdi:window-closed", 527 | "openwindow_offset", 528 | ) 529 | 530 | 531 | class TemperatureOffsetSensor(BaseRadiatorSensor): 532 | def __init__(self, radiator, device, unique_id): 533 | super().__init__( 534 | radiator, 535 | device, 536 | unique_id, 537 | "Temperature Offset", 538 | "mdi:thermometer", 539 | "temperature_offset", 540 | ) 541 | 542 | @property 543 | def native_value(self): 544 | """Convert the temperature offset from two digits to a decimal format.""" 545 | offset = self._radiator.get("temperature_offset", None) 546 | if offset is not None: 547 | return offset / 10.0 # Convert two-digit value to decimal 548 | return "Unknown" # Default if offset is not available 549 | 550 | 551 | class HysteresisSensor(BaseRadiatorSensor): 552 | def __init__(self, radiator, device, unique_id): 553 | super().__init__( 554 | radiator, device, unique_id, "Hysteresis", "mdi:sine-wave", "hysteresis" 555 | ) 556 | 557 | 558 | class VocSensor(BaseRadiatorSensor): 559 | def __init__(self, radiator, device, unique_id): 560 | super().__init__(radiator, device, unique_id, "VOC", "mdi:air-filter", "voc") 561 | 562 | 563 | class Co2Sensor(BaseRadiatorSensor): 564 | def __init__(self, radiator, device, unique_id): 565 | super().__init__(radiator, device, unique_id, "CO2", "mdi:molecule-co2", "co2") 566 | 567 | 568 | class OpenWindowDetectedSensor(BaseRadiatorSensor): 569 | def __init__(self, radiator, device, unique_id): 570 | super().__init__( 571 | radiator, 572 | device, 573 | unique_id, 574 | "Open Window Detected", 575 | "mdi:window-open", 576 | "openwindow_detected", 577 | ) 578 | 579 | @property 580 | def native_value(self): 581 | """Return 'Open' if window is detected open (1), otherwise 'Closed' (0).""" 582 | status = self._radiator.get("openwindow_detected", None) 583 | if status == 1: 584 | return "Open" 585 | elif status == 0: 586 | return "Closed" 587 | return "Unknown" # Default if status is not available 588 | 589 | 590 | class LastUpdateSensor(SensorEntity): 591 | def __init__(self, radiator, device, unique_id): 592 | self._radiator = radiator 593 | self._device = device # Store device reference 594 | self._attr_name = f"{radiator['serial']} Last Update" 595 | self._attr_unique_id = unique_id 596 | self._attr_icon = "mdi:update" 597 | self._attr_native_value = None # Initialize the sensor value 598 | 599 | @property 600 | def native_value(self): 601 | # Retrieve the last update timestamp 602 | last_update_raw = self._radiator.get("last_update") 603 | 604 | # Convert the timestamp to a readable format if it exists 605 | if last_update_raw: 606 | # Parse the ISO string 607 | last_update_dt = datetime.fromisoformat( 608 | last_update_raw.replace("Z", "+00:00") 609 | ) 610 | 611 | # Convert to the local timezone configured in Home Assistant 612 | local_tz = dt_util.DEFAULT_TIME_ZONE 613 | last_update_local = last_update_dt.astimezone(local_tz) 614 | return last_update_local.strftime( 615 | "%d-%m-%Y %H:%M" 616 | ) # Format as "DD-MM-YYYY HH:MM" 617 | 618 | return "N/A" # Return a default if `last_update` is missing 619 | 620 | @property 621 | def unique_id(self): 622 | return self._attr_unique_id 623 | 624 | @property 625 | def device_info(self): 626 | return { 627 | "identifiers": {(DOMAIN, self._radiator["serial"])}, 628 | "name": f"{self._radiator['serial']}", 629 | "model": self._radiator.get("model", "Unknown Model"), 630 | "manufacturer": "IRSAP", 631 | "sw_version": self._radiator.get("firmware", "Unknown Firmware"), 632 | } 633 | 634 | async def async_update(self): 635 | """Update the sensor value periodically.""" 636 | self._attr_native_value = ( 637 | self.native_value 638 | ) # Trigger conversion in native_value 639 | _LOGGER.debug(f"Updated {self._attr_name} to {self._attr_native_value}") 640 | 641 | 642 | class LockSensor(BaseRadiatorSensor): 643 | def __init__(self, radiator, device, unique_id): 644 | super().__init__(radiator, device, unique_id, "Child Lock", "mdi:lock", "lock") 645 | 646 | @property 647 | def native_value(self): 648 | """Return 'Locked' if child lock is active (1), otherwise 'Unlocked' (0).""" 649 | lock_status = self._radiator.get("lock", None) 650 | if lock_status == 1: 651 | return "Locked" 652 | elif lock_status == 0: 653 | return "Unlocked" 654 | return "Unknown" # Default value if lock status is not available 655 | 656 | 657 | class AIQSensor(BaseRadiatorSensor): 658 | def __init__(self, radiator, device, unique_id): 659 | super().__init__( 660 | radiator, device, unique_id, "Air Quality", "mdi:air-purifier", "aiq" 661 | ) 662 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/light.py: -------------------------------------------------------------------------------- 1 | import time 2 | from .const import DOMAIN, USER_POOL_ID, CLIENT_ID, REGION 3 | import logging 4 | from homeassistant.components.light import LightEntity, ColorMode 5 | import aiohttp 6 | import json 7 | from warrant import Cognito 8 | from .device_manager import device_manager 9 | import asyncio 10 | import colorsys 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | async def async_setup_entry(hass, config_entry, async_add_entities): 16 | from .climate import RadiatorClimate # Importa RadiatorClimate qui 17 | 18 | envID = config_entry.data["envID"] 19 | username = config_entry.data["username"] 20 | password = config_entry.data["password"] 21 | 22 | token = await hass.async_add_executor_job(login_with_srp, username, password) 23 | 24 | if token and envID: 25 | _LOGGER.debug("Token and envID successfully obtained. Retrieving sensors.") 26 | 27 | hass.data.setdefault(DOMAIN, {}) 28 | hass.data[DOMAIN]["token"] = token 29 | hass.data[DOMAIN]["envID"] = envID 30 | hass.data[DOMAIN]["username"] = username 31 | hass.data[DOMAIN]["password"] = password 32 | 33 | devices = device_manager.get_devices() # Ottieni i dispositivi dal manager 34 | _LOGGER.debug( 35 | f"Devices found: {[device.radiator['serial'] for device in devices]}" 36 | ) 37 | 38 | if not devices: 39 | _LOGGER.error( 40 | "No devices found. Please ensure that climate entities are set up correctly." 41 | ) 42 | return 43 | 44 | # Ottieni i dati delle luci 45 | radiators_lights = await get_light_data(token, envID) 46 | _LOGGER.info(f"Radiators lights data: {radiators_lights}") 47 | 48 | # Crea entità light 49 | lights = [] 50 | for key, radiator in radiators_lights: # Itera sulle tuple (key, value) 51 | if ( 52 | isinstance(radiator, dict) and "serial" in radiator 53 | ): # Verifica che il valore sia valido 54 | unique_id = f"{radiator['serial']}" 55 | lights.append( 56 | RadiatorLight( 57 | radiator, 58 | unique_id, 59 | unique_id=f"{radiator['serial']}_led", 60 | ) 61 | ) 62 | else: 63 | _LOGGER.warning( 64 | f"Skipping invalid radiator data for key {key}: {radiator}" 65 | ) 66 | 67 | async_add_entities(lights) 68 | else: 69 | _LOGGER.error("Unable to obtain the token or envID. Check configuration.") 70 | 71 | 72 | def login_with_srp(username, password): 73 | "Log in and obtain the access token using Warrant." 74 | try: 75 | _LOGGER.debug(f"Starting login process with username: {username}") 76 | u = Cognito(USER_POOL_ID, CLIENT_ID, username=username, user_pool_region=REGION) 77 | _LOGGER.debug("Cognito object created successfully.") 78 | u.authenticate(password=password) 79 | _LOGGER.debug(f"Access Token: {u.access_token}") 80 | return u.access_token 81 | except TypeError as te: 82 | _LOGGER.error(f"Type error during login: {te}") 83 | except Exception as e: 84 | _LOGGER.error(f"Unhandled error during login: {e}") 85 | return None 86 | 87 | 88 | async def get_light_data(token, envID): 89 | "Fetch radiator data from the API." 90 | url = ( 91 | "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 92 | ) 93 | headers = { 94 | "Authorization": f"Bearer {token}", 95 | "Content-Type": "application/json", 96 | } 97 | graphql_query = { 98 | "operationName": "GetShadow", 99 | "variables": {"envId": envID}, 100 | "query": "query GetShadow($envId: ID!) {\n getShadow(envId: $envId) {\n envId\n payload\n __typename\n }\n}\n", 101 | } 102 | 103 | try: 104 | async with aiohttp.ClientSession() as session: 105 | async with session.post( 106 | url, json=graphql_query, headers=headers 107 | ) as response: 108 | if response.status == 200: 109 | data = await response.json() 110 | payload = json.loads(data["data"]["getShadow"]["payload"]) 111 | _LOGGER.debug(f"Payload retrieved from API: {payload}") 112 | 113 | return extract_device_info(payload["state"]["desired"]) 114 | else: 115 | _LOGGER.error(f"API request error: {response.status}") 116 | return [] 117 | except ValueError as e: 118 | _LOGGER.error(f"Error converting temperature: {e}") 119 | return [] 120 | except Exception as e: 121 | _LOGGER.error(f"Error during API call: {e}") 122 | return [] 123 | 124 | 125 | import re 126 | 127 | 128 | def extract_device_info( 129 | payload, 130 | nam_suffix="_NAM", 131 | led_suffix="_X_LED_MSP", # Suffisso per luce 132 | led_existance="_X_LED_CSP", # Suffisso per verificare se c'è il led 133 | led_enable_suffix="_X_LED_ENB", 134 | exclude_suffix="E_NAM", 135 | ): 136 | devices_info = [] 137 | temp_devices = {} # Memorizza temporaneamente i dispositivi in attesa di verifica del LED 138 | 139 | def find_device_keys(obj): 140 | if isinstance(obj, dict): 141 | for key, value in obj.items(): 142 | # Memorizza le chiavi _NAM per dispositivi potenziali 143 | if key.endswith(nam_suffix) and not key.startswith(exclude_suffix): 144 | temp_devices[key] = { 145 | "serial": value, 146 | "state": False, # Default a OFF 147 | "color": {"h": 0, "s": 0, "v": 0}, # Default colore 148 | "has_led": False, # Flag per la verifica del LED 149 | } 150 | 151 | # Verifica la presenza del LED 152 | if ( 153 | key.endswith(led_existance) 154 | and key.replace(led_existance, nam_suffix) in temp_devices 155 | ): 156 | temp_devices[key.replace(led_existance, nam_suffix)]["has_led"] = ( 157 | value is not None 158 | ) 159 | 160 | # Gestisci stato accensione LED 161 | if ( 162 | key.endswith(led_enable_suffix) 163 | and key.replace(led_enable_suffix, nam_suffix) in temp_devices 164 | ): 165 | temp_devices[key.replace(led_enable_suffix, nam_suffix)][ 166 | "state" 167 | ] = value == 1 168 | 169 | # Gestisci colore LED 170 | if key.endswith(led_suffix) and isinstance(value, dict): 171 | nam_key = key.replace(led_suffix, nam_suffix) 172 | if nam_key in temp_devices: 173 | led_data = value.get("p", {}) 174 | color_data = led_data.get("v", {}) 175 | temp_devices[nam_key]["color"] = { 176 | "h": color_data.get("h", 0), 177 | "s": color_data.get("s", 0), 178 | "v": color_data.get("v", 0), 179 | } 180 | 181 | # Ricorsione per chiavi annidate 182 | find_device_keys(value) 183 | elif isinstance(obj, list): 184 | for item in obj: 185 | find_device_keys(item) 186 | 187 | # Esegui la ricerca nel payload 188 | find_device_keys(payload) 189 | 190 | # Filtra i dispositivi che hanno il LED 191 | devices_info = [ 192 | (key, device) for key, device in temp_devices.items() if device["has_led"] 193 | ] 194 | 195 | return devices_info 196 | 197 | 198 | class RadiatorLight(LightEntity): 199 | "Representation of a radiator light entity." 200 | 201 | def __init__(self, radiator, device, unique_id): 202 | self._radiator = radiator 203 | self._device = device # Store device reference 204 | self._attr_name = f"{radiator['serial']} LED" 205 | self._attr_unique_id = unique_id 206 | self._attr_icon = "mdi:led-strip-variant" 207 | self._radiator_serial = radiator["serial"] 208 | self._model = radiator.get("model", "Modello Sconosciuto") 209 | self._attr_native_value = radiator.get("ip_address", "IP non disponibile") 210 | self._sw_version = radiator.get("firmware") 211 | self._attr_is_on = radiator.get("state", False) 212 | # Convertire h, s, v in RGBW 213 | h = radiator["color"]["h"] 214 | s = radiator["color"]["s"] 215 | v = radiator["color"]["v"] 216 | self._attr_rgbw_color = self.hsv_to_rgbw(h, s, v) 217 | self._attr_brightness = (radiator["color"]["v"] / 100) * 255 218 | self._attr_supported_color_modes = {ColorMode.RGBW} 219 | 220 | def hsv_to_rgbw(self, h, s, v): 221 | "Converte HSV in RGBW." 222 | h = h / 360.0 223 | s = s / 100.0 224 | v = v / 100.0 225 | 226 | r, g, b = colorsys.hsv_to_rgb(h, s, v) 227 | 228 | w = min(r, g, b) 229 | 230 | r = int((r - w) * 255) 231 | g = int((g - w) * 255) 232 | b = int((b - w) * 255) 233 | w = int(w * 255) 234 | 235 | return (r, g, b, w) 236 | 237 | def _convert_rgbw_to_hsv(self, rgbw): 238 | "Converte un colore RGBW in HSV." 239 | red, green, blue, white = rgbw 240 | 241 | # Rimuovi il contributo del bianco per ottenere il colore RGB puro 242 | red -= white 243 | green -= white 244 | blue -= white 245 | 246 | # Assicurati che i valori non vadano sotto zero 247 | red = max(0, red) 248 | green = max(0, green) 249 | blue = max(0, blue) 250 | 251 | # Normalizza i valori RGB in un range 0-1 252 | red_norm = red / 255.0 253 | green_norm = green / 255.0 254 | blue_norm = blue / 255.0 255 | 256 | # Calcola HSV utilizzando formule standard 257 | max_val = max(red_norm, green_norm, blue_norm) 258 | min_val = min(red_norm, green_norm, blue_norm) 259 | delta = max_val - min_val 260 | 261 | # Calcola Hue (tonalità) 262 | if delta == 0: 263 | hue = 0 264 | elif max_val == red_norm: 265 | hue = ((green_norm - blue_norm) / delta) % 6 266 | elif max_val == green_norm: 267 | hue = ((blue_norm - red_norm) / delta) + 2 268 | else: # max_val == blue_norm 269 | hue = ((red_norm - green_norm) / delta) + 4 270 | hue = round(hue * 60) # Converti in gradi 271 | if hue < 0: 272 | hue += 360 273 | 274 | # Calcola Saturazione 275 | saturation = 0 if max_val == 0 else (delta / max_val) 276 | saturation = round(saturation * 100) # Scala su 0-100 277 | 278 | # Calcola Valore 279 | value = round(max_val * 100) # Scala su 0-100 280 | 281 | return hue, saturation, value 282 | 283 | @property 284 | def native_value(self): 285 | return self._attr_native_value 286 | 287 | @property 288 | def unique_id(self): 289 | return self._attr_unique_id 290 | 291 | @property 292 | def device_info(self): 293 | "Associa il sensore al dispositivo climate con il seriale corrispondente." 294 | return { 295 | "identifiers": { 296 | (DOMAIN, self._radiator["serial"]) 297 | }, # Use device serial number 298 | "name": f"{self._radiator['serial']}", 299 | "model": self._radiator.get("model", "Unknown Model"), 300 | "manufacturer": "IRSAP", 301 | "sw_version": self._radiator.get("firmware", "Unknown Firmware"), 302 | } 303 | 304 | @property 305 | def is_on(self): 306 | return self._attr_is_on 307 | 308 | @property 309 | def brightness(self): 310 | "Return the brightness of the light." 311 | return self._attr_brightness 312 | 313 | @property 314 | def rgbw_color(self): 315 | "Return the RGBW color of the light." 316 | return self._attr_rgbw_color 317 | 318 | @property 319 | def color_mode(self): 320 | "Return the color mode of the light." 321 | return ColorMode.RGBW 322 | 323 | async def async_update(self): 324 | "Aggiorna lo stato della luce dal dispositivo." 325 | if getattr(self, "_pending_update", False): 326 | self._pending_update = False 327 | return 328 | 329 | _LOGGER.debug(f"Updating radiator light {self._attr_name}") 330 | 331 | # Configurazione iniziale 332 | device_name = self._attr_name.replace("LED", "").strip() 333 | username = self.hass.data[DOMAIN]["username"] 334 | password = self.hass.data[DOMAIN]["password"] 335 | envID = self.hass.data[DOMAIN].get("envID") 336 | token = self.hass.data[DOMAIN].get("token") 337 | 338 | retry_count = 0 339 | max_retries = 1 340 | last_valid_brightness = self._attr_brightness 341 | last_valid_hs_color = self._attr_hs_color 342 | 343 | while retry_count < max_retries: 344 | retry_count += 1 345 | _LOGGER.debug( 346 | f"Attempt {retry_count}: Retrieving payload for {self._attr_name}" 347 | ) 348 | 349 | if not token or not envID: 350 | # Recupera un nuovo token e il payload 351 | token = await self.hass.async_add_executor_job( 352 | login_with_srp, username, password 353 | ) 354 | _LOGGER.error("Token or envID not found in hass.data") 355 | return 356 | 357 | payload = await self.get_current_payload(token, envID) 358 | if payload is None: 359 | # Regenerate token and retry 360 | token = await self.hass.async_add_executor_job( 361 | login_with_srp, username, password 362 | ) 363 | if not token: 364 | _LOGGER.error("Failed to regenerate token") 365 | return None, None 366 | # Try to retrieve payload again 367 | payload = await self.get_current_payload(token, envID) 368 | 369 | # Ottieni informazioni dal payload 370 | desired_payload = payload.get("state", {}).get("desired", {}) 371 | if self._extract_light_info(desired_payload, device_name): 372 | break 373 | else: 374 | _LOGGER.warning( 375 | f"Light info is incomplete for {self._attr_name}. Retrying..." 376 | ) 377 | await asyncio.sleep(1) 378 | 379 | async def async_turn_on(self, **kwargs): 380 | await asyncio.sleep(1) 381 | "Turn on the light with the specified settings." 382 | _LOGGER.debug(f"Turning on {self._attr_name} with kwargs: {kwargs}") 383 | 384 | # Imposta la luminosità, se specificata, o utilizza quella attuale 385 | brightness = kwargs.get("brightness", self._attr_brightness) 386 | if brightness is not None: 387 | # Converti la luminosità da Home Assistant (0-255) a IRSAP (0-100) 388 | brightness_irsap = round((brightness / 255) * 100) 389 | self._attr_brightness = brightness 390 | 391 | # Imposta il colore RGBW, se specificato 392 | if "rgbw_color" in kwargs: 393 | rgbw = kwargs["rgbw_color"] 394 | self._attr_rgbw_color = rgbw 395 | hsv = self._convert_rgbw_to_hsv(rgbw) 396 | h, s, v = hsv 397 | 398 | else: 399 | # Usa i valori HSV esistenti per mantenere il colore corrente 400 | hsv = self._convert_rgbw_to_hsv(self._attr_rgbw_color) 401 | h, s, v = hsv 402 | 403 | username = self.hass.data[DOMAIN].get("username") 404 | password = self.hass.data[DOMAIN].get("password") 405 | envID = self.hass.data[DOMAIN].get("envID") 406 | token = self.hass.data[DOMAIN].get("token") 407 | 408 | if not token or not envID: 409 | token = await self.hass.async_add_executor_job( 410 | login_with_srp, username, password 411 | ) 412 | _LOGGER.error("Token or envID not found in hass.data") 413 | return 414 | 415 | # Function to handle token regeneration and payload retrieval 416 | async def retrieve_payload(token, envID): 417 | "Attempt to retrieve the payload, regenerate the token if it fails" 418 | payload = await self.get_current_payload(token, envID) 419 | if payload is None: 420 | # Regenerate token and retry 421 | token = await self.hass.async_add_executor_job( 422 | login_with_srp, username, password 423 | ) 424 | if not token: 425 | _LOGGER.error("Failed to regenerate token") 426 | return None, None 427 | # Try to retrieve payload again 428 | payload = await self.get_current_payload(token, envID) 429 | return token, payload 430 | 431 | # Retrieve the payload and handle token regeneration if necessary 432 | token, payload = await retrieve_payload(token, envID) 433 | 434 | if payload is None: 435 | _LOGGER.error( 436 | f"Failed to retrieve payload after token regeneration for {self._attr_name}" 437 | ) 438 | return 439 | 440 | # Genera il payload con la funzione generate_device_payload 441 | updated_payload = await self.generate_device_payload( 442 | payload=payload, 443 | device_name=self._attr_name.replace("LED", "").strip(), 444 | light_state=True, # Stato della luce (accensione) 445 | color=(h, s, v), # Passa i valori HSV 446 | brightness=brightness_irsap, # Passa la luminosità in scala 0-100 447 | ) 448 | 449 | # Invia il nuovo payload aggiornato alle API 450 | success = await self._send_light_status_to_api(token, envID, updated_payload) 451 | if success: 452 | # Aggiorna lo stato interno 453 | self._attr_is_on = True 454 | self.async_write_ha_state() 455 | else: 456 | _LOGGER.error( 457 | f"Failed to update color and status as ON for {self._attr_name}" 458 | ) 459 | 460 | async def async_turn_off(self, **kwargs): 461 | await asyncio.sleep(1) 462 | "Turn on the light with the specified settings." 463 | _LOGGER.debug(f"Turning off {self._attr_name} with kwargs: {kwargs}") 464 | 465 | # Imposta la luminosità, se specificata, o utilizza quella attuale 466 | brightness = kwargs.get("brightness", self._attr_brightness) 467 | if brightness is not None: 468 | # Converti la luminosità da Home Assistant (0-255) a IRSAP (0-100) 469 | brightness_irsap = round((brightness / 255) * 100) 470 | self._attr_brightness = brightness 471 | 472 | # Imposta il colore RGBW, se specificato 473 | if "rgbw_color" in kwargs: 474 | rgbw = kwargs["rgbw_color"] 475 | self._attr_rgbw_color = rgbw 476 | hsv = self._convert_rgbw_to_hsv(rgbw) 477 | h, s, v = hsv 478 | 479 | else: 480 | # Usa i valori HSV esistenti per mantenere il colore corrente 481 | hsv = self._convert_rgbw_to_hsv(self._attr_rgbw_color) 482 | h, s, v = hsv 483 | 484 | username = self.hass.data[DOMAIN].get("username") 485 | password = self.hass.data[DOMAIN].get("password") 486 | envID = self.hass.data[DOMAIN].get("envID") 487 | token = self.hass.data[DOMAIN].get("token") 488 | 489 | if not token or not envID: 490 | token = await self.hass.async_add_executor_job( 491 | login_with_srp, username, password 492 | ) 493 | _LOGGER.error("Token or envID not found in hass.data") 494 | return 495 | 496 | # Function to handle token regeneration and payload retrieval 497 | async def retrieve_payload(token, envID): 498 | "Attempt to retrieve the payload, regenerate the token if it fails" 499 | payload = await self.get_current_payload(token, envID) 500 | if payload is None: 501 | # Regenerate token and retry 502 | token = await self.hass.async_add_executor_job( 503 | login_with_srp, username, password 504 | ) 505 | if not token: 506 | _LOGGER.error("Failed to regenerate token") 507 | return None, None 508 | # Try to retrieve payload again 509 | payload = await self.get_current_payload(token, envID) 510 | return token, payload 511 | 512 | # Retrieve the payload and handle token regeneration if necessary 513 | token, payload = await retrieve_payload(token, envID) 514 | 515 | if payload is None: 516 | _LOGGER.error( 517 | f"Failed to retrieve payload after token regeneration for {self._attr_name}" 518 | ) 519 | return 520 | 521 | # Genera il payload con la funzione generate_device_payload 522 | updated_payload = await self.generate_device_payload( 523 | payload=payload, 524 | device_name=self._attr_name.replace("LED", "").strip(), 525 | light_state=False, # Stato della luce (accensione) 526 | color=(h, s, v), # Passa i valori HSV 527 | brightness=0, # Passa la luminosità in scala 0-100 528 | ) 529 | 530 | # Invia il nuovo payload aggiornato alle API 531 | success = await self._send_light_status_to_api(token, envID, updated_payload) 532 | if success: 533 | # Aggiorna lo stato interno 534 | self._attr_is_on = True 535 | self.async_write_ha_state() 536 | else: 537 | _LOGGER.error( 538 | f"Failed to update color and status as ON for {self._attr_name}" 539 | ) 540 | 541 | def _extract_light_info(self, desired_payload, device_name): 542 | "Estrae informazioni sulla luce dal payload." 543 | for key, value in desired_payload.items(): 544 | if key.endswith("_NAM") and value == device_name: 545 | base_key = key[:-4] 546 | msp_key = f"{base_key}_X_LED_MSP" 547 | enb_key = f"{base_key}_X_LED_ENB" 548 | 549 | msp_value = desired_payload.get(msp_key) 550 | enb_value = desired_payload.get(enb_key) 551 | 552 | if msp_value and "p" in msp_value and "v" in msp_value["p"]: 553 | hsv = msp_value["p"]["v"] 554 | h, s, v = hsv.get("h", 0), hsv.get("s", 0), hsv.get("v", 0) 555 | h = h / 360.0 556 | s = s / 100.0 557 | v = v / 100.0 558 | 559 | r, g, b = colorsys.hsv_to_rgb(h, s, v) 560 | 561 | w = min(r, g, b) 562 | 563 | r = int((r - w) * 255) 564 | g = int((g - w) * 255) 565 | b = int((b - w) * 255) 566 | w = int(w * 255) 567 | self._attr_rgbw_color = (r, g, b, w) 568 | self._attr_brightness = (hsv.get("v", 255) / 100) * 255 569 | self._attr_is_on = enb_value == 1 570 | return True 571 | return False 572 | 573 | async def generate_device_payload( 574 | self, payload, device_name, light_state=None, color=None, brightness=None 575 | ): 576 | # Aggiorna il timestamp con il tempo attuale in millisecondi 577 | current_timestamp = int(time.time() * 1000) # Tempo attuale in millisecondi 578 | payload["timestamp"] = current_timestamp # Aggiorna il timestamp nel payload 579 | 580 | payload["clientId"] = ( 581 | "app-now2-1.9.38-2143-ios-bdd093f2-8e08-4541-8a7e-800c23274f21" # Fake clientId iOS 582 | ) 583 | 584 | # "Aggiorna il payload del dispositivo con una nuova temperatura o stato di accensione/spegnimento" 585 | desired_payload = payload.get("state", {}).get( 586 | "desired", {} 587 | ) # Accedi a payload["state"]["desired"] 588 | 589 | timestamp_24h_future = int(time.time()) + 24 * 3600 590 | time_24h_future = time.strftime( 591 | "%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(timestamp_24h_future) 592 | ) 593 | 594 | # Cerca il device nel payload basato sul nome 595 | for key, value in desired_payload.items(): 596 | if key.endswith("_NAM") and value == device_name: 597 | base_key = key[:-4] # Ottieni la chiave di base senza il suffisso 598 | 599 | # Aggiorna la temperatura se fornita 600 | if light_state is not None: 601 | # Aggiorna _MSP 602 | led_enb_key = f"{base_key}_X_LED_ENB" 603 | desired_payload[led_enb_key] = 1 if light_state else 0 604 | 605 | led_msp_key = f"{base_key}_X_LED_MSP" 606 | if led_msp_key in desired_payload: 607 | desired_payload[led_msp_key] = { 608 | "p": { 609 | "u": 1, 610 | "v": {"h": color[0], "s": color[1], "v": brightness}, 611 | "m": 4, 612 | } 613 | } 614 | 615 | # Rimuovi 'sk' se esistente 616 | desired_payload.pop("sk", None) 617 | 618 | # Aggiorna il payload originale 619 | payload["state"]["desired"] = desired_payload 620 | 621 | # Riordina il payload secondo l'ordine richiesto 622 | ordered_payload = { 623 | "id": payload.get("id"), 624 | "clientId": payload.get("clientId"), 625 | "timestamp": payload.get("timestamp"), 626 | "version": payload.get("version"), 627 | "state": payload["state"], 628 | } 629 | 630 | return ordered_payload # Restituisci il payload aggiornato 631 | 632 | # Funzione per inviare il payload aggiornato alle API 633 | async def _send_light_status_to_api(self, token, envID, updated_payload): 634 | "Invia il payload aggiornato alle API." 635 | url = "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 636 | headers = { 637 | "Authorization": f"Bearer {token}", 638 | "Content-Type": "application/json", 639 | } 640 | 641 | json_payload = json.dumps(updated_payload) 642 | 643 | graphql_query = { 644 | "operationName": "UpdateShadow", 645 | "variables": {"envId": envID, "payload": json_payload}, 646 | "query": ( 647 | "mutation UpdateShadow($envId: ID!, $payload: AWSJSON!) {\n asyncUpdateShadow(envId: $envId, payload: $payload) {\n status\n code\n message\n payload\n __typename\n }\n}\n" 648 | ), 649 | } 650 | 651 | try: 652 | async with aiohttp.ClientSession() as session: 653 | async with session.post( 654 | url, json=graphql_query, headers=headers 655 | ) as response: 656 | if response.status == 200: 657 | return True 658 | else: 659 | _LOGGER.error( 660 | f"API request error: {response.status} - {await response.text()}" 661 | ) 662 | return False 663 | except Exception as e: 664 | _LOGGER.error(f"Error sending payload to API: {e}") 665 | return False 666 | 667 | async def get_current_payload(self, token, envID): 668 | "Fetch the current device payload from the API." 669 | url = "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 670 | headers = { 671 | "Authorization": f"Bearer {token}", 672 | "Content-Type": "application/json", 673 | } 674 | 675 | graphql_query = { 676 | "operationName": "GetShadow", 677 | "variables": {"envId": envID}, 678 | "query": "query GetShadow($envId: ID!) {\n getShadow(envId: $envId) {\n envId\n payload\n __typename\n }\n}\n", 679 | } 680 | 681 | try: 682 | async with aiohttp.ClientSession() as session: 683 | async with session.post( 684 | url, json=graphql_query, headers=headers 685 | ) as response: 686 | if response.status == 200: 687 | data = await response.json() 688 | payload = json.loads(data["data"]["getShadow"]["payload"]) 689 | return payload 690 | else: 691 | return None 692 | except Exception as e: 693 | _LOGGER.error(f"Exception during payload retrieval: {e}") 694 | return None 695 | -------------------------------------------------------------------------------- /custom_components/irsap_ha/climate.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from homeassistant.components.climate import ( 5 | ClimateEntity, 6 | HVACMode, 7 | ) 8 | from homeassistant.components.climate.const import ClimateEntityFeature 9 | from homeassistant.const import UnitOfTemperature 10 | from homeassistant.helpers.entity_platform import ( 11 | AddEntitiesCallback, 12 | ) 13 | from homeassistant.util import datetime, timedelta # Importa UnitOfTemperature 14 | from .const import DOMAIN, USER_POOL_ID, CLIENT_ID, REGION 15 | import aiohttp 16 | import json 17 | from warrant import Cognito 18 | import re 19 | from .device import RadiatorDevice 20 | from .device_manager import device_manager 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | async def async_setup_entry( 26 | hass, config_entry, async_add_entities: AddEntitiesCallback 27 | ): 28 | from .sensor import RadiatorSensor 29 | 30 | """Set up climate platform.""" 31 | envID = config_entry.data["envID"] 32 | username = config_entry.data["username"] 33 | password = config_entry.data["password"] 34 | 35 | token = await hass.async_add_executor_job(login_with_srp, username, password) 36 | 37 | if token and envID: 38 | _LOGGER.debug("Token and envID successfully obtained. Retrieving radiators.") 39 | 40 | # Salviamo token ed envID nel contesto di Home Assistant 41 | hass.data.setdefault(DOMAIN, {}) 42 | hass.data[DOMAIN]["token"] = token 43 | hass.data[DOMAIN]["envID"] = envID 44 | hass.data[DOMAIN]["username"] = username 45 | hass.data[DOMAIN]["password"] = password 46 | 47 | radiators = await get_radiators(token, envID) 48 | _LOGGER.debug( 49 | f"Retrieved radiators: {radiators}" 50 | ) # Log per verificare i radiatori 51 | 52 | for r in radiators: 53 | device = RadiatorDevice(r, token, envID) 54 | device_manager.add_device(device) # Aggiungi il dispositivo al manager 55 | climate_entity = RadiatorClimate( 56 | r, token, envID, unique_id=f"{r['serial']}_climate" 57 | ) 58 | async_add_entities([climate_entity], True) 59 | 60 | else: 61 | _LOGGER.error("Unable to obtain the token or envID. Check configuration.") 62 | 63 | 64 | def login_with_srp(username, password): 65 | "Log in and obtain the access token using Warrant." 66 | try: 67 | u = Cognito(USER_POOL_ID, CLIENT_ID, username=username, user_pool_region=REGION) 68 | u.authenticate(password=password) 69 | _LOGGER.debug(f"Access Token: {u.access_token}") 70 | return u.access_token 71 | except Exception as e: 72 | _LOGGER.error(f"Error during login: {e}") 73 | return None 74 | 75 | 76 | async def get_radiators(token, envID): 77 | "Fetch radiator data from the API." 78 | url = ( 79 | "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 80 | ) 81 | headers = { 82 | "Authorization": f"Bearer {token}", 83 | "Content-Type": "application/json", 84 | } 85 | graphql_query = { 86 | "operationName": "GetShadow", 87 | "variables": {"envId": envID}, 88 | "query": "query GetShadow($envId: ID!) {\n getShadow(envId: $envId) {\n envId\n payload\n __typename\n }\n}\n", 89 | } 90 | 91 | try: 92 | async with aiohttp.ClientSession() as session: 93 | async with session.post( 94 | url, json=graphql_query, headers=headers 95 | ) as response: 96 | if response.status == 200: 97 | data = await response.json() 98 | payload = json.loads(data["data"]["getShadow"]["payload"]) 99 | _LOGGER.debug(f"Payload retrieved from API: {payload}") 100 | 101 | return extract_device_info(payload["state"]["desired"]) 102 | else: 103 | _LOGGER.error(f"API request error: {response.status}") 104 | return [] 105 | except ValueError as e: 106 | _LOGGER.error(f"Error converting temperature: {e}") 107 | return [] 108 | except Exception as e: 109 | _LOGGER.error(f"Error during API call: {e}") 110 | return [] 111 | 112 | 113 | def extract_device_info( 114 | payload, 115 | nam_suffix="_NAM", 116 | tmp_suffix="_TMP", 117 | enb_suffix="_ENB", 118 | srl_suffix="_SRL", 119 | exclude_suffix="E_NAM", 120 | ): 121 | devices_info = [] 122 | 123 | # Suffixi di interesse per le chiavi 124 | suffixes = [ 125 | "_SRL", 126 | "_FWV", 127 | "_TYP", 128 | "_X_ipAddress", 129 | ] 130 | 131 | # Trova tutte le chiavi _NAM, _SRL, _FWV, _TYP e _X_ipAddress in ordine 132 | nam_keys = [] 133 | srl_keys = [] 134 | fwv_keys = [] 135 | typ_keys = [] 136 | ip_keys = [] 137 | 138 | def find_device_keys(obj): 139 | if isinstance(obj, dict): 140 | for key, value in obj.items(): 141 | # Raccogli chiavi _NAM e aggiungi i dettagli iniziali 142 | if key.endswith(nam_suffix) and not key.startswith(exclude_suffix): 143 | device_info = { 144 | "serial": value, 145 | "temperature": 0, # Default a 0 se non trovata 146 | "state": "OFF", # Default a OFF se non trovato 147 | } 148 | nam_keys.append((key, device_info)) 149 | 150 | # Trova chiavi _SRL, _FWV, _TYP, _X_ipAddress e aggiungile agli elenchi 151 | if any(key.endswith(suffix) for suffix in suffixes): 152 | if key.endswith("_SRL"): 153 | srl_keys.append((key, value)) 154 | elif key.endswith("_FWV"): 155 | fwv_keys.append((key, value)) 156 | elif key.endswith("_TYP"): 157 | typ_keys.append((key, value)) 158 | elif key.endswith("_X_ipAddress"): 159 | ip_keys.append((key, value)) 160 | 161 | # Ricorsione per trovare chiavi nested 162 | find_device_keys(value) 163 | elif isinstance(obj, list): 164 | for item in obj: 165 | find_device_keys(item) 166 | 167 | # Esegui la ricerca di chiavi nel payload 168 | find_device_keys(payload) 169 | 170 | # Associa ogni _NAM ai suoi corrispondenti attributi in ordine di apparizione 171 | for i, (nam_key, device_info) in enumerate(nam_keys): 172 | base_key = nam_key[: -len(nam_suffix)] 173 | corresponding_tmp_key = base_key + tmp_suffix 174 | corresponding_enb_key = base_key + enb_suffix 175 | 176 | # Trova la temperatura 177 | if corresponding_tmp_key in payload: 178 | tmp_value = payload[corresponding_tmp_key] 179 | device_info["temperature"] = ( 180 | float(tmp_value) / 10 if tmp_value is not None else 0 181 | ) 182 | 183 | # Trova lo stato (ON/OFF) 184 | if corresponding_enb_key in payload: 185 | enb_value = payload[corresponding_enb_key] 186 | device_info["state"] = "HEAT" if enb_value == 1 else "OFF" 187 | 188 | # Associa SRL, FWV, TYP e IP in base alla posizione dell'indice 189 | if i < len(srl_keys): 190 | device_info["mac"] = srl_keys[i][1] 191 | if i < len(fwv_keys): 192 | device_info["firmware"] = fwv_keys[i][1] 193 | if i < len(typ_keys): 194 | device_info["model"] = typ_keys[i][1] 195 | if i < len(ip_keys): 196 | device_info["ip_address"] = ip_keys[i][1] 197 | 198 | devices_info.append(device_info) 199 | 200 | return devices_info 201 | 202 | 203 | def find_device_key_by_name(payload, device_name, nam_suffix="_NAM"): 204 | "Trova la chiave del dispositivo in base al nome." 205 | for key, value in payload.items(): 206 | if key.endswith(nam_suffix) and value == device_name: 207 | return key[ 208 | : -len(nam_suffix) 209 | ] # Restituisce il prefisso del dispositivo (es. 'PCM', 'PTO') 210 | return None 211 | 212 | 213 | class RadiatorClimate(ClimateEntity): 214 | "Representation of a radiator climate entity." 215 | 216 | def __init__(self, radiator, token, envID, unique_id): 217 | self._radiator = radiator 218 | self._device = RadiatorDevice(radiator, token, envID) 219 | self._attr_name = f"{radiator['serial']} Radiator" 220 | self._attr_unique_id = unique_id 221 | self._current_temperature = radiator.get("temperature", 0) 222 | self._target_temperature = 18.0 # Imposta una temperatura target predefinita 223 | self._state = radiator["state"] # Usa il valore di _ENB per lo stato 224 | self._token = token 225 | self._envID = envID 226 | self._serial_number = radiator.get("mac") 227 | self._sw_version = radiator.get("firmware") 228 | self._model = radiator.get("model") 229 | 230 | # Modalità HVAC supportate (HEAT, OFF) 231 | self._attr_hvac_modes = [ 232 | HVACMode.HEAT, 233 | HVACMode.OFF, 234 | ] # Usa HVACMode per le modalità 235 | 236 | # Imposta la modalità HVAC in base allo stato corrente 237 | if self._state == "HEAT": 238 | self._attr_hvac_mode = HVACMode.HEAT 239 | elif self._state == "OFF": 240 | self._attr_hvac_mode = HVACMode.OFF 241 | else: 242 | self._attr_hvac_mode = ( 243 | HVACMode.OFF 244 | ) # Fallback in caso di valori non riconosciuti 245 | 246 | # Funzionalità supportate (es. temperatura target e accensione/spegnimento) 247 | self._attr_supported_features = ( 248 | ClimateEntityFeature.TARGET_TEMPERATURE 249 | | ClimateEntityFeature.TURN_ON 250 | | ClimateEntityFeature.TURN_OFF 251 | ) 252 | 253 | _LOGGER.debug( 254 | f"Initialized {self._attr_name} with state {self._attr_hvac_mode}" 255 | ) 256 | 257 | @property 258 | def min_temp(self): 259 | "Restituisce la temperatura minima raggiungibile." 260 | return 12 # Temperatura minima di 12 gradi 261 | 262 | @property 263 | def max_temp(self): 264 | "Restituisce la temperatura massima raggiungibile." 265 | return 32 # Temperatura massima di 32 gradi 266 | 267 | @property 268 | def name(self): 269 | "Return the name of the climate device." 270 | return self._attr_name 271 | 272 | @property 273 | def unique_id(self): 274 | "Return a unique ID for the climate device." 275 | return self._attr_unique_id 276 | 277 | @property 278 | def temperature_unit(self): 279 | "Return the unit of measurement." 280 | return UnitOfTemperature.CELSIUS # Usa UnitOfTemperature 281 | 282 | @property 283 | def current_temperature(self): 284 | "Return the current temperature." 285 | return self._current_temperature 286 | 287 | @property 288 | def target_temperature(self): 289 | "Return the target temperature." 290 | return self._target_temperature 291 | 292 | @property 293 | def hvac_mode(self): 294 | "Return current HVAC mode." 295 | return self._attr_hvac_mode 296 | 297 | @property 298 | def hvac_modes(self): 299 | "Return available HVAC modes." 300 | return self._attr_hvac_modes 301 | 302 | @property 303 | def extra_state_attributes(self): 304 | """Return additional attributes like IP address.""" 305 | return { 306 | "min_temperature": self._radiator.get("min_temperature"), 307 | "max_temperature": self._radiator.get("max_temperature"), 308 | } 309 | 310 | @property 311 | def device_info(self): 312 | """Return device information.""" 313 | return { 314 | "identifiers": { 315 | (DOMAIN, self._device.radiator["serial"]) 316 | }, # Use device serial number 317 | "name": self._device.radiator["serial"], 318 | "model": self._device.radiator.get("model", "Unknown model"), 319 | "manufacturer": "IRSAP", 320 | "sw_version": self._device.radiator.get("firmware", "unknown"), 321 | } 322 | 323 | # Funzione per inviare il payload aggiornato alle API 324 | async def _send_target_temperature_to_api(self, token, envID, updated_payload): 325 | "Invia il payload aggiornato alle API." 326 | url = "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 327 | headers = { 328 | "Authorization": f"Bearer {token}", 329 | "Content-Type": "application/json", 330 | } 331 | 332 | json_payload = json.dumps(updated_payload) 333 | 334 | graphql_query = { 335 | "operationName": "UpdateShadow", 336 | "variables": {"envId": envID, "payload": json_payload}, 337 | "query": ( 338 | "mutation UpdateShadow($envId: ID!, $payload: AWSJSON!) {\n asyncUpdateShadow(envId: $envId, payload: $payload) {\n status\n code\n message\n payload\n __typename\n }\n}\n" 339 | ), 340 | } 341 | 342 | try: 343 | async with aiohttp.ClientSession() as session: 344 | async with session.post( 345 | url, json=graphql_query, headers=headers 346 | ) as response: 347 | if response.status == 200: 348 | return True 349 | else: 350 | _LOGGER.error( 351 | f"API request error: {response.status} - {await response.text()}" 352 | ) 353 | return False 354 | except Exception as e: 355 | _LOGGER.error(f"Error sending payload to API: {e}") 356 | return False 357 | 358 | async def find_device_key_by_name(payload, device_name, nam_suffix="_NAM"): 359 | "Trova la chiave del dispositivo in base al nome." 360 | for key, value in payload.items(): 361 | if key.endswith(nam_suffix) and value == device_name: 362 | return key[ 363 | : -len(nam_suffix) 364 | ] # Restituisci il prefisso (es. 'PCM', 'PTO') 365 | return None 366 | 367 | async def generate_device_payload( 368 | self, payload, device_name, temperature=None, enable=None 369 | ): 370 | # Aggiorna il timestamp con il tempo attuale in millisecondi 371 | current_timestamp = int(time.time() * 1000) # Tempo attuale in millisecondi 372 | payload["timestamp"] = current_timestamp # Aggiorna il timestamp nel payload 373 | 374 | payload["clientId"] = ( 375 | "app-now2-1.9.38-2143-ios-bdd093f2-8e08-4541-8a7e-800c23274f21" # Fake clientId iOS 376 | ) 377 | 378 | # "Aggiorna il payload del dispositivo con una nuova temperatura o stato di accensione/spegnimento" 379 | desired_payload = payload.get("state", {}).get( 380 | "desired", {} 381 | ) # Accedi a payload["state"]["desired"] 382 | 383 | timestamp_24h_future = int(time.time()) + 24 * 3600 384 | time_24h_future = time.strftime( 385 | "%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(timestamp_24h_future) 386 | ) 387 | 388 | # Controlla la pianificazione `E_SCH` per ciascun radiatore 389 | num_radiatori = sum(1 for key in desired_payload if key.endswith("_NAM")) 390 | has_scheduling = len(desired_payload.get("E_SCH", [])) == num_radiatori 391 | 392 | # Cerca il device nel payload basato sul nome 393 | for key, value in desired_payload.items(): 394 | if key.endswith("_NAM") and value == device_name: 395 | base_key = key[:-4] # Ottieni la chiave di base senza il suffisso 396 | 397 | # Aggiorna la temperatura se fornita 398 | if temperature is not None: 399 | # Aggiorna _MSP 400 | msp_key = f"{base_key}_MSP" 401 | if msp_key in desired_payload: 402 | if "p" in desired_payload[msp_key]: 403 | desired_payload[msp_key]["p"]["v"] = int(temperature * 10) 404 | 405 | tsp_key = f"{base_key}_TSP" 406 | if tsp_key in desired_payload: 407 | desired_payload[tsp_key] = { 408 | "p": { 409 | "u": 0, 410 | "v": int(temperature * 10), 411 | "m": 3, 412 | "k": "TEMPORARY", 413 | }, 414 | "e": time_24h_future 415 | if has_scheduling 416 | else "1970-01-01T00:00:00.000Z", 417 | } 418 | 419 | # Imposta _MOD in base alla logica definita sopra 420 | mod_key = f"{base_key}_MOD" 421 | desired_payload[mod_key] = 2 if has_scheduling else 1 422 | 423 | # Aggiorna _CSP 424 | csp_key = f"{base_key}_CSP" 425 | if csp_key in desired_payload: 426 | if "p" in desired_payload[csp_key]: 427 | desired_payload[csp_key]["p"]["v"] = int(temperature * 10) 428 | 429 | # Aggiorna E_CLL se presente, impostandolo a 1 430 | ecll_key = "E_CLL" 431 | if ecll_key in desired_payload: 432 | desired_payload[ecll_key] = 1 # Imposta E_CLL a 1 433 | 434 | # Aggiorna E_CPC se presente, impostandolo a 1 435 | ecpc_key = "E_CPC" 436 | if ecpc_key in desired_payload: 437 | desired_payload[ecpc_key] = 1 # Imposta E_CPC a 1 438 | 439 | break 440 | 441 | # Rimuovi 'sk' se esistente 442 | desired_payload.pop("sk", None) 443 | 444 | # Aggiorna il payload originale 445 | payload["state"]["desired"] = desired_payload 446 | 447 | # Riordina il payload secondo l'ordine richiesto 448 | ordered_payload = { 449 | "id": payload.get("id"), 450 | "clientId": payload.get("clientId"), 451 | "timestamp": payload.get("timestamp"), 452 | "version": payload.get("version"), 453 | "state": payload["state"], 454 | } 455 | 456 | return ordered_payload # Restituisci il payload aggiornato 457 | 458 | async def generate_state_payload(self, payload, device_name, enable): 459 | "Aggiorna il payload del dispositivo solo per lo stato di accensione/spegnimento." 460 | current_timestamp = int(time.time() * 1000) # Tempo attuale in millisecondi 461 | payload["timestamp"] = current_timestamp # Aggiorna il timestamp nel payload 462 | 463 | desired_payload = payload.get("state", {}).get( 464 | "desired", {} 465 | ) # Accedi a payload["state"]["desired"] 466 | 467 | # Cerca il device nel payload basato sul nome 468 | for key, value in desired_payload.items(): 469 | if key.endswith("_NAM") and value == device_name: 470 | base_key = key[:-4] # Ottieni la chiave di base senza il suffisso 471 | 472 | # Aggiorna lo stato di accensione/spegnimento se fornito 473 | enable_key = f"{base_key}_ENB" 474 | if enable_key in desired_payload: 475 | desired_payload[enable_key] = enable # Imposta a 1 (on) o 0 (off) 476 | 477 | # Aggiorna _CLL se presente, impostandolo a 1 478 | cll_key = f"{base_key}_CLL" 479 | if cll_key in desired_payload: 480 | desired_payload[cll_key] = 1 # Imposta _CLL a 1 481 | 482 | break 483 | 484 | # Rimuovi 'sk' se esistente 485 | desired_payload.pop("sk", None) 486 | 487 | # Aggiorna il payload originale 488 | payload["state"]["desired"] = desired_payload 489 | 490 | # Riordina il payload secondo l'ordine richiesto 491 | ordered_payload = { 492 | "id": payload.get("id"), 493 | "clientId": payload.get("clientId"), 494 | "timestamp": payload.get("timestamp"), 495 | "version": payload.get("version"), 496 | "state": payload.get("state"), 497 | } 498 | 499 | return ordered_payload # Restituisci il payload aggiornato 500 | 501 | async def generate_device_payload_for_hvac( 502 | self, payload, device_name, hvac_mode=None, enable=None 503 | ): 504 | # Aggiorna il timestamp con il tempo attuale in millisecondi 505 | current_timestamp = int(time.time() * 1000) # Tempo attuale in millisecondi 506 | payload["timestamp"] = current_timestamp # Aggiorna il timestamp nel payload 507 | 508 | payload["clientId"] = ( 509 | "app-now2-1.9.38-2143-ios-bdd093f2-8e08-4541-8a7e-800c23274f21" # Aggiorna il clientId facendo finta di essere l'App su iOS 510 | ) 511 | 512 | # "Aggiorna il payload del dispositivo con una nuova temperatura o stato di accensione/spegnimento" 513 | desired_payload = payload.get("state", {}).get( 514 | "desired", {} 515 | ) # Accedi a payload["state"]["desired"] 516 | 517 | # Trova il dispositivo specifico e aggiorna solo quello 518 | device_found = False 519 | for key, value in desired_payload.items(): 520 | if key.endswith("_NAM") and value == device_name: 521 | base_key = key[:-4] # Ottieni la chiave di base senza il suffisso 522 | 523 | if hvac_mode is not None: 524 | enable_key = f"{base_key}_ENB" 525 | if enable_key in desired_payload: 526 | # Set the value based on the hvac_mode 527 | desired_payload[enable_key] = 1 if hvac_mode == 1 else 0 528 | device_found = True 529 | break # Esci dal ciclo una volta trovato e aggiornato il dispositivo 530 | 531 | # Se il dispositivo non è stato trovato, non fare nulla 532 | if not device_found: 533 | return payload # Restituisci il payload invariato 534 | 535 | # Rimuovi 'sk' se esistente 536 | desired_payload.pop("sk", None) 537 | 538 | # Aggiorna il payload originale 539 | payload["state"]["desired"] = desired_payload 540 | 541 | # Riordina il payload secondo l'ordine richiesto 542 | ordered_payload = { 543 | "id": payload.get("id"), 544 | "clientId": payload.get("clientId"), 545 | "timestamp": payload.get("timestamp"), 546 | "version": payload.get("version"), 547 | "state": payload.get("state"), 548 | } 549 | 550 | # Serializza il payload in JSON per garantire che None sia convertito in null 551 | json_payload_str = json.dumps(ordered_payload) 552 | # Deserializza il JSON per ottenere il payload nel formato corretto 553 | final_payload = json.loads(json_payload_str) 554 | 555 | return final_payload 556 | 557 | async def get_current_payload(self, token, envID): 558 | "Fetch the current device payload from the API." 559 | url = "https://flqpp5xzjzacpfpgkloiiuqizq.appsync-api.eu-west-1.amazonaws.com/graphql" 560 | headers = { 561 | "Authorization": f"Bearer {token}", 562 | "Content-Type": "application/json", 563 | } 564 | 565 | graphql_query = { 566 | "operationName": "GetShadow", 567 | "variables": {"envId": envID}, 568 | "query": "query GetShadow($envId: ID!) {\n getShadow(envId: $envId) {\n envId\n payload\n __typename\n }\n}\n", 569 | } 570 | 571 | try: 572 | async with aiohttp.ClientSession() as session: 573 | async with session.post( 574 | url, json=graphql_query, headers=headers 575 | ) as response: 576 | if response.status == 200: 577 | data = await response.json() 578 | payload = json.loads(data["data"]["getShadow"]["payload"]) 579 | return payload 580 | else: 581 | return None 582 | except Exception as e: 583 | _LOGGER.error(f"Exception during payload retrieval: {e}") 584 | return None 585 | 586 | # Modifica la funzione per accettare altri argomenti tramite kwargs 587 | async def async_set_temperature(self, **kwargs): 588 | await asyncio.sleep(1) 589 | self._pending_update = True # Imposta il flag per evitare l'update 590 | 591 | "Imposta la temperatura target del radiatore." 592 | temperature = kwargs.get("temperature") # Estrae la temperatura dai kwargs 593 | 594 | username = self.hass.data[DOMAIN].get("username") 595 | password = self.hass.data[DOMAIN].get("password") 596 | 597 | token = await self.hass.async_add_executor_job( 598 | login_with_srp, username, password 599 | ) 600 | envID = self.hass.data[DOMAIN].get("envID") 601 | 602 | if not token or not envID: 603 | _LOGGER.error("Token or envID not found in hass.data") 604 | return 605 | 606 | # Function to handle token regeneration and payload retrieval 607 | async def retrieve_payload(token, envID): 608 | "Attempt to retrieve the payload, regenerate the token if it fails" 609 | payload = await self.get_current_payload(token, envID) 610 | if payload is None: 611 | # Regenerate token and retry 612 | token = await self.hass.async_add_executor_job( 613 | login_with_srp, username, password 614 | ) 615 | if not token: 616 | _LOGGER.error("Failed to regenerate token") 617 | return None, None 618 | # Try to retrieve payload again 619 | payload = await self.get_current_payload(token, envID) 620 | return token, payload 621 | 622 | # Retrieve the payload and handle token regeneration if necessary 623 | token, payload = await retrieve_payload(token, envID) 624 | 625 | if payload is None: 626 | _LOGGER.error( 627 | f"Failed to retrieve payload after token regeneration for {self._attr_name}" 628 | ) 629 | return 630 | 631 | # Aggiorna il payload con la nuova temperatura 632 | updated_payload = await self.generate_device_payload( # Add 'await' here if this method is async 633 | payload=payload, 634 | device_name=self._attr_name.replace("Radiator", "").strip(), 635 | temperature=temperature, # Passa la temperatura come keyword argument 636 | ) 637 | 638 | # Invia il nuovo payload aggiornato alle API 639 | success = await self._send_target_temperature_to_api( 640 | token, envID, updated_payload 641 | ) 642 | if success: 643 | self._target_temperature = temperature 644 | # Cambia lo stato in HEAT 645 | self._attr_hvac_mode = HVACMode.HEAT 646 | else: 647 | _LOGGER.error(f"Failed to update temperature for {self._attr_name}") 648 | 649 | async def async_set_hvac_mode(self, hvac_mode): 650 | if self._attr_hvac_mode == hvac_mode: 651 | _LOGGER.debug( 652 | f"HVAC mode for {self._attr_name} is already {hvac_mode}, skipping update" 653 | ) 654 | return 655 | await asyncio.sleep(1) 656 | self._pending_update = True # Imposta il flag per evitare l'update 657 | "Set new target HVAC mode." 658 | username = self.hass.data[DOMAIN].get("username") 659 | password = self.hass.data[DOMAIN].get("password") 660 | 661 | token = await self.hass.async_add_executor_job( 662 | login_with_srp, username, password 663 | ) 664 | envID = self.hass.data[DOMAIN].get("envID") 665 | 666 | if not token or not envID: 667 | _LOGGER.error("Token or envID not found in hass.data") 668 | return 669 | 670 | if hvac_mode == HVACMode.OFF: 671 | _LOGGER.debug(f"Setting {self._radiator['serial']} to OFF") 672 | 673 | # Ottieni il payload attuale del dispositivo dalle API 674 | payload = await self.get_current_payload(token, envID) 675 | 676 | if payload is None: 677 | _LOGGER.error( 678 | f"Failed to retrieve current payload for {self._attr_name}" 679 | ) 680 | return 681 | 682 | # Aggiorna il payload con la nuova temperatura 683 | updated_payload = await self.generate_device_payload_for_hvac( # Add 'await' here if this method is async 684 | payload=payload, 685 | device_name=self._attr_name.replace("Radiator", "").strip(), 686 | hvac_mode=0, 687 | ) 688 | 689 | # Invia il nuovo payload aggiornato alle API 690 | success = await self._send_target_temperature_to_api( 691 | token, envID, updated_payload 692 | ) 693 | 694 | elif hvac_mode == HVACMode.HEAT: 695 | _LOGGER.debug(f"Setting {self._radiator['serial']} to HEAT") 696 | 697 | # Ottieni il payload attuale del dispositivo dalle API 698 | payload = await self.get_current_payload(token, envID) 699 | 700 | if payload is None: 701 | _LOGGER.error( 702 | f"Failed to retrieve current payload for {self._attr_name}" 703 | ) 704 | return 705 | 706 | # Aggiorna il payload con la nuova temperatura 707 | updated_payload = await self.generate_device_payload_for_hvac( # Add 'await' here if this method is async 708 | payload=payload, 709 | device_name=self._attr_name.replace("Radiator", "").strip(), 710 | hvac_mode=1, 711 | ) 712 | 713 | # Invia il nuovo payload aggiornato alle API 714 | success = await self._send_target_temperature_to_api( 715 | token, envID, updated_payload 716 | ) 717 | 718 | else: 719 | _LOGGER.error(f"Unsupported HVAC mode: {hvac_mode}") 720 | return 721 | 722 | if success: 723 | # Aggiorna la modalità HVAC attuale 724 | self._attr_hvac_mode = hvac_mode 725 | self.async_write_ha_state() 726 | else: 727 | _LOGGER.error(f"Failed to update HVAC mode for {self._attr_name}") 728 | 729 | async def async_update(self): 730 | if getattr(self, "_pending_update", False): 731 | # Evita l'aggiornamento se è in corso un'impostazione temperatura 732 | self._pending_update = False 733 | return 734 | _LOGGER.debug(f"Updating radiator climate {self._attr_name}") 735 | 736 | # Rimuove "Radiator" dal nome dell'entità, se presente, per facilitare il matching 737 | device_name = self._attr_name.replace("Radiator", "").strip() 738 | 739 | # Recupera token e envID 740 | username = self.hass.data[DOMAIN]["username"] 741 | password = self.hass.data[DOMAIN]["password"] 742 | envID = self.hass.data[DOMAIN].get("envID") 743 | 744 | retry_count = 0 745 | max_retries = 3 746 | tmp_value = None 747 | last_valid_temperature = ( 748 | self._current_temperature 749 | ) # Memorizza l'ultima temperatura valida 750 | 751 | while tmp_value is None and retry_count < max_retries: 752 | retry_count += 1 753 | _LOGGER.debug( 754 | f"Attempt {retry_count}: Retrieving payload for {self._attr_name}" 755 | ) 756 | 757 | # Ottieni un nuovo token e payload ad ogni retry 758 | token = await self.hass.async_add_executor_job( 759 | login_with_srp, username, password 760 | ) 761 | if not token or not envID: 762 | _LOGGER.error("Token or envID not found in hass.data") 763 | return 764 | 765 | payload = await self.get_current_payload(token, envID) 766 | if payload is None: 767 | _LOGGER.debug( 768 | f"Failed to retrieve payload for {self._attr_name} on attempt {retry_count}" 769 | ) 770 | await asyncio.sleep(1) # Attende prima di riprovare 771 | continue 772 | 773 | # Accesso al desired_payload 774 | desired_payload = payload.get("state", {}).get("desired", {}) 775 | for key, value in desired_payload.items(): 776 | if key.endswith("_NAM") and value == device_name: 777 | base_key = key[:-4] # Ottieni la chiave base 778 | tmp_key = f"{base_key}_TMP" 779 | msp_key = f"{base_key}_MSP" 780 | enb_key = f"{base_key}_ENB" 781 | 782 | # Ottieni la temperatura 783 | tmp_value = desired_payload.get(tmp_key, None) 784 | if ( 785 | tmp_value is not None 786 | ): # Se il valore è valido, esci dal ciclo di retry 787 | self._current_temperature = tmp_value / 10 788 | 789 | # Ottieni la temperatura 790 | msp_value = desired_payload.get(msp_key, None) 791 | if ( 792 | msp_value["p"]["v"] is not None 793 | ): # Se il valore è valido, esci dal ciclo di retry 794 | self._target_temperature = msp_value["p"]["v"] / 10 795 | 796 | if tmp_value is None: 797 | _LOGGER.debug( 798 | f"Temperature is None for {self._attr_name}. Retrying..." 799 | ) 800 | await asyncio.sleep(1) # Attende prima di riprovare 801 | 802 | # Se la temperatura è None dopo i tentativi, registra un avviso e imposta a 0 803 | if tmp_value is None: 804 | _LOGGER.debug( 805 | f"Temperature for {self._attr_name} remains None after {retry_count} retries;" 806 | ) 807 | self._current_temperature = last_valid_temperature 808 | await self.hass.services.async_call( 809 | "persistent_notification", 810 | "create", 811 | { 812 | "title": f"Device {self._attr_name} Issue", 813 | "message": "Temperature is set to previous state due to an invalid value received (None). Please check the device and try to reset it.", 814 | "notification_id": f"radiator_{self._attr_name}_temperature_warning", 815 | }, 816 | ) 817 | else: 818 | await self.hass.services.async_call( 819 | "persistent_notification", 820 | "dismiss", 821 | {"notification_id": f"radiator_{self._attr_name}_temperature_warning"}, 822 | ) 823 | 824 | # Controlla e aggiorna modalità di funzionamento (es. HEAT, OFF) 825 | if enb_key in desired_payload: 826 | enb_value = desired_payload.get(enb_key, 0) 827 | self._attr_hvac_mode = HVACMode.HEAT if enb_value == 1 else HVACMode.OFF 828 | 829 | _LOGGER.debug( 830 | f"Final state for {self._attr_name}: Temperature={self._current_temperature}, HVAC mode={self._attr_hvac_mode}" 831 | ) 832 | --------------------------------------------------------------------------------