From 1d36d79e37055493e197142bb32464f48bf23a56 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 25 Aug 2024 01:45:59 +0200 Subject: [PATCH 01/33] Fix deprecated forward entry setup method --- custom_components/hass_pontos/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index d516355..bef771f 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Register sensors hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, 'sensor') + hass.config_entries.async_forward_entry_setups(entry, ['sensor']) ) # Register services From 668c82c40e93029cd8188e72107d7566a9fa80cb Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 25 Aug 2024 01:46:23 +0200 Subject: [PATCH 02/33] Add services --- custom_components/hass_pontos/services.py | 38 +++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/custom_components/hass_pontos/services.py b/custom_components/hass_pontos/services.py index b1fdefc..f9089a4 100644 --- a/custom_components/hass_pontos/services.py +++ b/custom_components/hass_pontos/services.py @@ -1,3 +1,37 @@ +import logging +import aiohttp + +from .const import DOMAIN, SERVICES, BASE_URL + +LOGGER = logging.getLogger(__name__) + async def register_services(hass): - # Placeholder - pass + async def async_send_command(ip_address, endpoint): + """Helper function to send commands to the device.""" + url = BASE_URL.format(ip=ip_address) + endpoint + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + LOGGER.info(f"Successfully sent command to {endpoint}.") + else: + LOGGER.error(f"Failed to send command to {endpoint}: HTTP {response.status}") + + async def async_service_handler(call, service_name): + """General service handler to handle different services.""" + for entry_data in hass.data[DOMAIN].values(): + ip_address = entry_data.get("ip_address") + endpoint = SERVICES[service_name]["endpoint"] + await async_send_command(ip_address, endpoint) + + # Dynamically register all services defined in SERVICES + for service_name in SERVICES: + async def service_handler(call, service_name=service_name): + await async_service_handler(call, service_name) + + hass.services.async_register( + DOMAIN, + service_name, + service_handler, + schema=None + ) From a2c79b6208789bd70af676a763eaf14e1d9b1b7b Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 25 Aug 2024 21:57:36 +0200 Subject: [PATCH 03/33] Fix indentation in manifest --- custom_components/hass_pontos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index cc03ed4..36a567b 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -8,7 +8,7 @@ "dependencies": [], "documentation": "https://github.com/sangvikh/hass-pontos", "iot_class": "local_polling", - "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", + "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", "requirements": [], "version": "1.0.4" } \ No newline at end of file From c97fc2c5c4338f028afe013a85a1f3982ee8a348 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 25 Aug 2024 22:39:27 +0200 Subject: [PATCH 04/33] Added missing services.yaml file --- custom_components/hass_pontos/services.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 custom_components/hass_pontos/services.yaml diff --git a/custom_components/hass_pontos/services.yaml b/custom_components/hass_pontos/services.yaml new file mode 100644 index 0000000..6fa33c8 --- /dev/null +++ b/custom_components/hass_pontos/services.yaml @@ -0,0 +1,14 @@ +open_valve: + name: Open Valve + description: Opens the valve. + fields: {} + +close_valve: + name: Close Valve + description: Closes the valve. + fields: {} + +clear_alarm: + name: Clear Alarms + description: Clears any active alarms. + fields: {} From 9e12d3780d1965d68be357df778748c19a958dcc Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Mon, 26 Aug 2024 01:18:47 +0200 Subject: [PATCH 05/33] Refactor device registration --- custom_components/hass_pontos/device.py | 56 +++++++++++++++++++++ custom_components/hass_pontos/manifest.json | 4 +- custom_components/hass_pontos/sensor.py | 43 ++-------------- custom_components/hass_pontos/utils.py | 16 ++++++ 4 files changed, 77 insertions(+), 42 deletions(-) create mode 100644 custom_components/hass_pontos/device.py create mode 100644 custom_components/hass_pontos/utils.py diff --git a/custom_components/hass_pontos/device.py b/custom_components/hass_pontos/device.py new file mode 100644 index 0000000..8662d34 --- /dev/null +++ b/custom_components/hass_pontos/device.py @@ -0,0 +1,56 @@ +import logging +from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from .const import DOMAIN, URL_LIST, CONF_IP_ADDRESS +from .utils import fetch_data + +LOGGER = logging.getLogger(__name__) + +# Cache to store device data +_device_cache = {} + +async def get_device_info(hass, entry): + entry_id = entry.entry_id + ip_address = entry.data[CONF_IP_ADDRESS] + + # Check if the data is already cached + if entry_id in _device_cache: + LOGGER.debug(f"Using cached data for device {entry_id}") + return _device_cache[entry_id]['device_info'], _device_cache[entry_id]['data'] + + LOGGER.debug(f"Fetching data for device {entry_id} from the device") + + # Fetching all relevant data from the device + data = await fetch_data(ip_address, URL_LIST) + + # Assign data to variables + mac_address = data.get("getMAC", "00:00:00:00:00:00:00:00") + serial_number = data.get("getSRN", "") + firmware_version = data.get("getVER", "") + device_type = data.get("getTYP", "") + + # Create a device entry with fetched data + device_registry = async_get_device_registry(hass) + device_info = { + "identifiers": {(DOMAIN, "pontos_base")}, + "connections": {(CONNECTION_NETWORK_MAC, mac_address)}, + "name": "Pontos Base", + "manufacturer": "Hansgrohe", + "model": device_type, + "sw_version": firmware_version, + "serial_number": serial_number, + } + + device = device_registry.async_get_or_create( + config_entry_id=entry_id, + **device_info + ) + + # Cache the device info and data + _device_cache[entry_id] = { + 'device_info': device_info, + 'data': data + } + + return device_info, data diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index 36a567b..300703d 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -1,6 +1,6 @@ { - "domain": "hass_pontos", - "name": "Hansgrohe Pontos", + "domain": "hass_pontos", + "name": "Hansgrohe Pontos", "codeowners": [ "@sangvikh" ], diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 12b2c78..1208111 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -1,10 +1,11 @@ -import aiohttp from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.device_registry import async_get as async_get_device_registry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC import logging +from .utils import fetch_data +from .device import get_device_info from .const import * LOGGER = logging.getLogger(__name__) @@ -46,19 +47,6 @@ def state(self): sensors = [PontosSensor(config) for key, config in SENSOR_DETAILS.items()] -# Fetching data -async def fetch_data(ip, url_list): - urls = [url.format(ip=ip) for url in url_list] - data = {} - async with aiohttp.ClientSession() as session: - for url in urls: - async with session.get(url) as response: - if response.status == 200: - data.update(await response.json()) - else: - LOGGER.error(f"Failed to fetch data: HTTP {response.status}") - return data - # Parsing sensor data def parse_data(data, sensor): """Process, format, and validate sensor data.""" @@ -87,32 +75,7 @@ def parse_data(data, sensor): async def async_setup_entry(hass, entry, async_add_entities): config = entry.data ip_address = config[CONF_IP_ADDRESS] - device_registry = async_get_device_registry(hass) - - # Fetching all relevant data from the device - data = await fetch_data(ip_address, URL_LIST) - - # Assign data to variables - mac_address = data.get("getMAC", "00:00:00:00:00:00:00:00") - serial_number = data.get("getSRN", "") - firmware_version = data.get("getVER", "") - device_type = data.get("getTYP", "") - - # Create a device entry with fetched data - device_info = { - "identifiers": {(DOMAIN, "pontos_base")}, - "connections": {(CONNECTION_NETWORK_MAC, mac_address)}, - "name": "Pontos Base", - "manufacturer": "Hansgrohe", - "model": device_type, - "sw_version": firmware_version, - "serial_number": serial_number, - } - - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - **device_info - ) + device_info, data = await get_device_info(hass, entry) # Assign device id to each sensor and add entities for sensor in sensors: diff --git a/custom_components/hass_pontos/utils.py b/custom_components/hass_pontos/utils.py new file mode 100644 index 0000000..dccdb2d --- /dev/null +++ b/custom_components/hass_pontos/utils.py @@ -0,0 +1,16 @@ +import aiohttp +import logging +LOGGER = logging.getLogger(__name__) + +# Fetching data +async def fetch_data(ip, url_list): + urls = [url.format(ip=ip) for url in url_list] + data = {} + async with aiohttp.ClientSession() as session: + for url in urls: + async with session.get(url) as response: + if response.status == 200: + data.update(await response.json()) + else: + LOGGER.error(f"Failed to fetch data: HTTP {response.status}") + return data \ No newline at end of file From daf1299cb40a02b37b6c08f0e53dcee3a60ca21a Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Mon, 26 Aug 2024 02:24:30 +0200 Subject: [PATCH 06/33] Added clear alarm button --- custom_components/hass_pontos/__init__.py | 4 +-- custom_components/hass_pontos/button.py | 35 +++++++++++++++++++++ custom_components/hass_pontos/const.py | 2 +- custom_components/hass_pontos/device.py | 3 +- custom_components/hass_pontos/manifest.json | 3 +- custom_components/hass_pontos/sensor.py | 3 +- 6 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 custom_components/hass_pontos/button.py diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index bef771f..2c57e2a 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -11,9 +11,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = entry.data - # Register sensors + # Register entities hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, ['sensor']) + hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button']) ) # Register services diff --git a/custom_components/hass_pontos/button.py b/custom_components/hass_pontos/button.py new file mode 100644 index 0000000..a81fc7d --- /dev/null +++ b/custom_components/hass_pontos/button.py @@ -0,0 +1,35 @@ +# custom_components/hass_pontos/button.py +import logging +from homeassistant.components.button import ButtonEntity +from homeassistant.helpers.entity import Entity +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the button entity for the Pontos Base device.""" + async_add_entities([PontosClearAlarmsButton(hass, entry)], True) + +class PontosClearAlarmsButton(ButtonEntity): + """Button to clear alarms on the Pontos Base device.""" + + def __init__(self, hass, entry): + """Initialize the button.""" + self._hass = hass + self._entry = entry + self._attr_name = "Clear Alarms" + self._attr_unique_id = f"{entry.entry_id}_clear_alarms" + self._attr_device_info = { + "identifiers": {(DOMAIN, "pontos_base")}, + "name": "Pontos Base", + "manufacturer": "Hansgrohe", + } + + async def async_press(self): + """Handle the button press to clear alarms.""" + LOGGER.info("Clear Alarms button pressed") + await self._hass.services.async_call( + DOMAIN, + "clear_alarms", # Assuming the service name for clearing alarms is "clear_alarms" + service_data={"entry_id": self._entry.entry_id} + ) diff --git a/custom_components/hass_pontos/const.py b/custom_components/hass_pontos/const.py index 4de5a1f..364e755 100644 --- a/custom_components/hass_pontos/const.py +++ b/custom_components/hass_pontos/const.py @@ -168,7 +168,7 @@ "name": "Close valve", "endpoint": "set/ab/2" }, - "clear_alarm": { + "clear_alarms": { "name": "Clear alarms", "endpoint": "clr/ala" }, diff --git a/custom_components/hass_pontos/device.py b/custom_components/hass_pontos/device.py index 8662d34..bd529dd 100644 --- a/custom_components/hass_pontos/device.py +++ b/custom_components/hass_pontos/device.py @@ -42,7 +42,8 @@ async def get_device_info(hass, entry): "serial_number": serial_number, } - device = device_registry.async_get_or_create( + # Register device in the device registry + device_registry.async_get_or_create( config_entry_id=entry_id, **device_info ) diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index 300703d..768eded 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -9,6 +9,7 @@ "documentation": "https://github.com/sangvikh/hass-pontos", "iot_class": "local_polling", "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", + "supported_features": ["sensor", "button"], "requirements": [], - "version": "1.0.4" + "version": "1.1.0" } \ No newline at end of file diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 1208111..cbe9669 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -73,8 +73,7 @@ def parse_data(data, sensor): return _data async def async_setup_entry(hass, entry, async_add_entities): - config = entry.data - ip_address = config[CONF_IP_ADDRESS] + ip_address = entry.data[CONF_IP_ADDRESS] device_info, data = await get_device_info(hass, entry) # Assign device id to each sensor and add entities From 1ff2c17709400e5c1cccf46ed7f276cd9773da1f Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Mon, 26 Aug 2024 19:02:39 +0200 Subject: [PATCH 07/33] Add dummy valve --- custom_components/hass_pontos/__init__.py | 2 +- custom_components/hass_pontos/manifest.json | 2 +- custom_components/hass_pontos/valve.py | 47 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 custom_components/hass_pontos/valve.py diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index 2c57e2a..abd2cfc 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Register entities hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button']) + hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button', 'valve']) ) # Register services diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index 768eded..e7ea5d4 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -9,7 +9,7 @@ "documentation": "https://github.com/sangvikh/hass-pontos", "iot_class": "local_polling", "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", - "supported_features": ["sensor", "button"], + "supported_features": ["sensor", "button", "valve"], "requirements": [], "version": "1.1.0" } \ No newline at end of file diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py new file mode 100644 index 0000000..08dd88e --- /dev/null +++ b/custom_components/hass_pontos/valve.py @@ -0,0 +1,47 @@ +# custom_components/hass_pontos/valve.py +import logging +from homeassistant.components.valve import ValveEntity +from .device import get_device_info # Import the get_device_info function +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the dummy valve entity.""" + device_info, _ = await get_device_info(hass, entry) + async_add_entities([DummyValve(entry, device_info)], True) + +class DummyValve(ValveEntity): + """A minimal implementation of a dummy valve entity.""" + + def __init__(self, entry, device_info): + """Initialize the dummy valve.""" + self._is_open = False # Initial state: valve is closed + self._attr_unique_id = f"{entry.entry_id}_dummy_valve" + self._attr_name = "Dummy Valve" + + # This is crucial to avoid the error + self._attr_reports_position = False + + # Link the entity to the pontos_base device + self._attr_device_info = device_info + + @property + def is_open(self) -> bool: + """Return true if the valve is open.""" + return self._is_open + + async def async_open(self, **kwargs) -> None: + """Open the valve.""" + self._is_open = True + self.async_write_ha_state() + + async def async_close(self, **kwargs) -> None: + """Close the valve.""" + self._is_open = False + self.async_write_ha_state() + + async def async_update(self): + """Update the valve's state. This could fetch data from an API in a real implementation.""" + # For a minimal example, we won't implement data fetching. The state is controlled by async_open and async_close. + pass From 527e2206000e6d682061a6344fd6bfa4cca5e2a5 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Mon, 26 Aug 2024 22:45:26 +0200 Subject: [PATCH 08/33] Working valve implementation --- custom_components/hass_pontos/__init__.py | 8 +- custom_components/hass_pontos/const.py | 13 ++- custom_components/hass_pontos/sensor.py | 2 - custom_components/hass_pontos/utils.py | 3 +- custom_components/hass_pontos/valve.py | 126 +++++++++++++++------- 5 files changed, 104 insertions(+), 48 deletions(-) diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index abd2cfc..b46b96f 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -11,17 +11,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = entry.data + # Register services + await register_services(hass) + # Register entities hass.async_create_task( hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button', 'valve']) ) - # Register services - await register_services(hass) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): await hass.config_entries.async_forward_entry_unload(entry, 'sensor') + await hass.config_entries.async_forward_entry_unload(entry, 'button') + await hass.config_entries.async_forward_entry_unload(entry, 'valve') hass.data[DOMAIN].pop(entry.entry_id) return True diff --git a/custom_components/hass_pontos/const.py b/custom_components/hass_pontos/const.py index 364e755..c711b09 100644 --- a/custom_components/hass_pontos/const.py +++ b/custom_components/hass_pontos/const.py @@ -1,4 +1,5 @@ from datetime import timedelta +from homeassistant.components.valve import STATE_OPEN, STATE_OPENING, STATE_CLOSED, STATE_CLOSING DOMAIN = "hass_pontos" CONF_IP_ADDRESS = "ip_address" @@ -43,10 +44,10 @@ } VALVE_CODES = { - "10": "Closed", - "11": "Closing", - "20": "Open", - "21": "Opening" + "10": STATE_CLOSED, + "11": STATE_CLOSING, + "20": STATE_OPEN, + "21": STATE_OPENING } SENSOR_DETAILS = { @@ -172,4 +173,6 @@ "name": "Clear alarms", "endpoint": "clr/ala" }, -} \ No newline at end of file +} + +VALVE_STATUS_SENSOR = "pontos_valve_status" \ No newline at end of file diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index cbe9669..eff9e04 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -1,7 +1,5 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC import logging from .utils import fetch_data diff --git a/custom_components/hass_pontos/utils.py b/custom_components/hass_pontos/utils.py index dccdb2d..b3a1b87 100644 --- a/custom_components/hass_pontos/utils.py +++ b/custom_components/hass_pontos/utils.py @@ -13,4 +13,5 @@ async def fetch_data(ip, url_list): data.update(await response.json()) else: LOGGER.error(f"Failed to fetch data: HTTP {response.status}") - return data \ No newline at end of file + return data + diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 08dd88e..bf055f6 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -1,47 +1,99 @@ -# custom_components/hass_pontos/valve.py import logging -from homeassistant.components.valve import ValveEntity -from .device import get_device_info # Import the get_device_info function -from .const import DOMAIN +from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveDeviceClass +from homeassistant.helpers.event import async_track_time_interval +from .device import get_device_info +from .const import DOMAIN, VALVE_STATUS_SENSOR, FETCH_INTERVAL LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up the dummy valve entity.""" + """Set up the valve entity.""" device_info, _ = await get_device_info(hass, entry) - async_add_entities([DummyValve(entry, device_info)], True) - -class DummyValve(ValveEntity): - """A minimal implementation of a dummy valve entity.""" - - def __init__(self, entry, device_info): - """Initialize the dummy valve.""" - self._is_open = False # Initial state: valve is closed - self._attr_unique_id = f"{entry.entry_id}_dummy_valve" - self._attr_name = "Dummy Valve" - - # This is crucial to avoid the error + + # Explicitly instantiate the PontosValve entity + valve_entity = PontosValve(hass, entry, device_info['identifiers']) + + # Add the entity to Home Assistant + async_add_entities([valve_entity], True) + + # Schedule periodic updates to check the valve state + async_track_time_interval(hass, valve_entity.async_update, FETCH_INTERVAL) + +class PontosValve(ValveEntity): + """Representation of the Pontos Valve entity.""" + + def __init__(self, hass, entry, device_id): + """Initialize the Pontos Valve.""" + self._hass = hass + self._entry = entry + self._attr_unique_id = "pontos_valve" + self._attr_name = "Pontos Valve" self._attr_reports_position = False + self._attr_device_class = ValveDeviceClass.WATER + self._state = None # Initial state will be set during the first update + self._device_id = device_id - # Link the entity to the pontos_base device - self._attr_device_info = device_info + @property + def state(self): + """Return the current state of the valve.""" + return self._state + + @property + def supported_features(self): + """Return the features supported by this valve.""" + return ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + @property + def unique_id(self): + """Return the unique ID of the valve.""" + return self._attr_unique_id @property - def is_open(self) -> bool: - """Return true if the valve is open.""" - return self._is_open - - async def async_open(self, **kwargs) -> None: - """Open the valve.""" - self._is_open = True - self.async_write_ha_state() - - async def async_close(self, **kwargs) -> None: - """Close the valve.""" - self._is_open = False - self.async_write_ha_state() - - async def async_update(self): - """Update the valve's state. This could fetch data from an API in a real implementation.""" - # For a minimal example, we won't implement data fetching. The state is controlled by async_open and async_close. - pass + def device_info(self): + """Return device info to link this entity with the device.""" + return { + "identifiers": self._device_id, + } + + def open_valve(self) -> None: + """Synchronously open the valve.""" + # Execute the async function within the event loop + self._hass.add_job(self.async_open) + + def close_valve(self) -> None: + """Synchronously close the valve.""" + # Execute the async function within the event loop + self._hass.add_job(self.async_close) + + async def async_open(self) -> None: + """Asynchronously open the valve.""" + await self._hass.services.async_call( + DOMAIN, + "open_valve", + service_data={"entry_id": self._entry.entry_id} + ) + + async def async_close(self) -> None: + """Asynchronously close the valve.""" + await self._hass.services.async_call( + DOMAIN, + "close_valve", + service_data={"entry_id": self._entry.entry_id} + ) + + async def async_update(self, time_event=None): + """Update the valve's state from the valve status sensor.""" + sensor_entity_id = f"sensor.{VALVE_STATUS_SENSOR}" + + # Fetch the state of the sensor + sensor_state = self._hass.states.get(sensor_entity_id) + + if sensor_state: + self._state = sensor_state.state + else: + LOGGER.error(f"Could not find sensor state for {sensor_entity_id}") + self._state = None # If sensor state is not found, set to None + + # Write the state to Home Assistant + if self.entity_id: + self.async_write_ha_state() From 7de44279cfb05ee4328244cee8c6ae8db51edf06 Mon Sep 17 00:00:00 2001 From: Harald Sangvik <43299500+sangvikh@users.noreply.github.com> Date: Tue, 27 Aug 2024 15:01:22 +0200 Subject: [PATCH 09/33] Remove invalid entry in manifest.json --- custom_components/hass_pontos/manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index e7ea5d4..baa0fb4 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -9,7 +9,6 @@ "documentation": "https://github.com/sangvikh/hass-pontos", "iot_class": "local_polling", "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", - "supported_features": ["sensor", "button", "valve"], "requirements": [], "version": "1.1.0" -} \ No newline at end of file +} From 87956864d08401f2778c44c229478a195a998bf3 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Tue, 27 Aug 2024 23:31:26 +0200 Subject: [PATCH 10/33] Update readme.md --- readme.md | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 2eb631b..2523d14 100644 --- a/readme.md +++ b/readme.md @@ -2,22 +2,46 @@ HACS integration for Hansgrohe Pontos -# Installation +## Features + +* Adds sensors for water consumption, water pressure, water temperature +++ +* Opening/Closing of water valve +* Clearing alarms + +## Installation + +Installation via HACS is the recommended method + +### HACS +Note: This integration is not yet included in HACS 1. Install HACS if you haven't already (see [installation guide](https://hacs.xyz/docs/configuration/basic/)). 2. Add custom repository https://github.com/sangvikh/hass-pontos as "Integration" in the settings tab of HACS. 3. Find and install Hansgrohe Pontos integration in HACS's "Integrations" tab. 4. Restart Home Assistant. -5. Go to your integrations page and click Add Integration and look for Hansgrohe Pontos. -6. Set up sensor using IP address of your pontos, fixed ip is reccomended +5. Go to your integrations page, click Add Integration and look for Hansgrohe Pontos. +6. Set up sensor using the IP address of your pontos, fixed ip is recommended. + +### Manual installation + +1. Clone repository or download as zip +2. Move the custom_components/hass_pontos folder to the custom_components directory of your Home Assistant installation +3. Restart Home Assistant. +5. Go to your integrations page, click Add Integration and look for Hansgrohe Pontos. +6. Set up sensor using the IP address of your pontos, fixed ip is recommended. -# Known issues +### Restful integration +The sensors and services can be added using restful integration. This is a bit limited and not recommended. -- Removing and then adding integration does not work without a restart inbetween. Sensors are not unregistered properly. +1. Copy the contents of restful.yaml into the configuration.yaml of your Home Assistant integration. +2. Find and replace IP address with the address of your Pontos. +3. Restart Home Assistant. # TODO -- [ ] Add services -- [ ] Add water valve button +- [x] Add services +- [x] Add water valve button +- [ ] Add profile selection - [ ] Read profile names +- [ ] Select active profile - [ ] Include in HACS \ No newline at end of file From f7df7b163d3d6fb2f8aa03fe6359fd41071eefb6 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Wed, 28 Aug 2024 18:26:30 +0200 Subject: [PATCH 11/33] Refactor sensors.py Fixes bug where sensors stop working after reloading integration --- custom_components/hass_pontos/sensor.py | 75 +++++++++---------------- custom_components/hass_pontos/utils.py | 24 ++++++++ 2 files changed, 49 insertions(+), 50 deletions(-) diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index eff9e04..228cab4 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -2,12 +2,36 @@ from homeassistant.helpers.event import async_track_time_interval import logging -from .utils import fetch_data +from .utils import fetch_data, parse_data from .device import get_device_info from .const import * LOGGER = logging.getLogger(__name__) +async def async_setup_entry(hass, entry, async_add_entities): + ip_address = entry.data[CONF_IP_ADDRESS] + device_info, data = await get_device_info(hass, entry) + + sensors = [PontosSensor(config) for key, config in SENSOR_DETAILS.items()] + + # Assign device id to each sensor and add entities + for sensor in sensors: + sensor.set_device_id(device_info['identifiers']) + async_add_entities(sensors) + + # Update data so sensors is available immediately + for sensor in sensors: + sensor.set_data(parse_data(data, sensor)) + + # Function to fetch new data and update all sensors + async def update_data(_): + data = await fetch_data(ip_address, URL_LIST) + for sensor in sensors: + sensor.set_data(parse_data(data, sensor)) + + # Schedule updates using the fetch interval + async_track_time_interval(hass, update_data, FETCH_INTERVAL) + class PontosSensor(SensorEntity): def __init__(self, sensor_config): self._data = None @@ -42,52 +66,3 @@ def device_info(self): @property def state(self): return self._data - -sensors = [PontosSensor(config) for key, config in SENSOR_DETAILS.items()] - -# Parsing sensor data -def parse_data(data, sensor): - """Process, format, and validate sensor data.""" - if data is None: - return None - _data = data.get(sensor._endpoint, None) - - # Apply format replacements if format_dict is present - if sensor._format_dict is not None and _data is not None: - for old, new in sensor._format_dict.items(): - _data = _data.replace(old, new) - - # Translate alarm codes if code_dict is present - if sensor._code_dict is not None and _data is not None: - _data = sensor._code_dict.get(_data, _data) - - # Scale sensor data if scale is present - if sensor._scale is not None and _data is not None: - try: - _data = float(_data) * sensor._scale - except (ValueError, TypeError): - pass - - return _data - -async def async_setup_entry(hass, entry, async_add_entities): - ip_address = entry.data[CONF_IP_ADDRESS] - device_info, data = await get_device_info(hass, entry) - - # Assign device id to each sensor and add entities - for sensor in sensors: - sensor.set_device_id(device_info['identifiers']) - async_add_entities(sensors) - - # Update data so sensors is available immediately - for sensor in sensors: - sensor.set_data(parse_data(data, sensor)) - - # Function to fetch new data and update all sensors - async def update_data(_): - data = await fetch_data(ip_address, URL_LIST) - for sensor in sensors: - sensor.set_data(parse_data(data, sensor)) - - # Schedule updates using the fetch interval - async_track_time_interval(hass, update_data, FETCH_INTERVAL) \ No newline at end of file diff --git a/custom_components/hass_pontos/utils.py b/custom_components/hass_pontos/utils.py index b3a1b87..bd1b006 100644 --- a/custom_components/hass_pontos/utils.py +++ b/custom_components/hass_pontos/utils.py @@ -15,3 +15,27 @@ async def fetch_data(ip, url_list): LOGGER.error(f"Failed to fetch data: HTTP {response.status}") return data +# Parsing sensor data +def parse_data(data, sensor): + """Process, format, and validate sensor data.""" + if data is None: + return None + _data = data.get(sensor._endpoint, None) + + # Apply format replacements if format_dict is present + if sensor._format_dict is not None and _data is not None: + for old, new in sensor._format_dict.items(): + _data = _data.replace(old, new) + + # Translate alarm codes if code_dict is present + if sensor._code_dict is not None and _data is not None: + _data = sensor._code_dict.get(_data, _data) + + # Scale sensor data if scale is present + if sensor._scale is not None and _data is not None: + try: + _data = float(_data) * sensor._scale + except (ValueError, TypeError): + pass + + return _data From 49515e0c1442fc4abcb2a0d846bf72ba19b9097a Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Wed, 28 Aug 2024 19:25:58 +0200 Subject: [PATCH 12/33] Pass device_info to sensor constructor --- custom_components/hass_pontos/sensor.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 228cab4..0febf81 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -12,11 +12,8 @@ async def async_setup_entry(hass, entry, async_add_entities): ip_address = entry.data[CONF_IP_ADDRESS] device_info, data = await get_device_info(hass, entry) - sensors = [PontosSensor(config) for key, config in SENSOR_DETAILS.items()] - - # Assign device id to each sensor and add entities - for sensor in sensors: - sensor.set_device_id(device_info['identifiers']) + # Instantiate and add sensors + sensors = [PontosSensor(sensor_config, device_info) for key, sensor_config in SENSOR_DETAILS.items()] async_add_entities(sensors) # Update data so sensors is available immediately @@ -33,7 +30,7 @@ async def update_data(_): async_track_time_interval(hass, update_data, FETCH_INTERVAL) class PontosSensor(SensorEntity): - def __init__(self, sensor_config): + def __init__(self, sensor_config, device_info): self._data = None self._attr_name = f"Pontos {sensor_config['name']}" self._endpoint = sensor_config['endpoint'] @@ -44,15 +41,12 @@ def __init__(self, sensor_config): self._code_dict = sensor_config.get('code_dict', None) self._scale = sensor_config.get('scale', None) self._attr_unique_id = f"pontos_{sensor_config['name']}" - self._device_id = None + self._device_id = device_info['identifiers'] def set_data(self, data): self._data = data self.async_write_ha_state() - def set_device_id(self, device_id): - self._device_id = device_id - @property def unique_id(self): return self._attr_unique_id From 0fbcc8715c88cf4d283b1d4badaf849e9f2cb6b6 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Wed, 28 Aug 2024 19:36:45 +0200 Subject: [PATCH 13/33] Explicit import in sensor.py --- custom_components/hass_pontos/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 0febf81..003e3a8 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -4,7 +4,7 @@ from .utils import fetch_data, parse_data from .device import get_device_info -from .const import * +from .const import CONF_IP_ADDRESS, SENSOR_DETAILS, FETCH_INTERVAL, URL_LIST LOGGER = logging.getLogger(__name__) From 4f39c3b0d1db9d51510ff51ccbf4519ca8c678a1 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Wed, 28 Aug 2024 20:43:12 +0200 Subject: [PATCH 14/33] Refactor device registration --- custom_components/hass_pontos/__init__.py | 6 +++++- custom_components/hass_pontos/device.py | 22 ++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index b46b96f..065cd4b 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -1,7 +1,8 @@ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry -from .services import register_services +from .services import register_services +from .device import register_device from .const import DOMAIN async def async_setup(hass: HomeAssistant, config: dict): @@ -11,6 +12,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = entry.data + # Register the device + await register_device(hass, entry) + # Register services await register_services(hass) diff --git a/custom_components/hass_pontos/device.py b/custom_components/hass_pontos/device.py index bd529dd..b85b41e 100644 --- a/custom_components/hass_pontos/device.py +++ b/custom_components/hass_pontos/device.py @@ -30,8 +30,6 @@ async def get_device_info(hass, entry): firmware_version = data.get("getVER", "") device_type = data.get("getTYP", "") - # Create a device entry with fetched data - device_registry = async_get_device_registry(hass) device_info = { "identifiers": {(DOMAIN, "pontos_base")}, "connections": {(CONNECTION_NETWORK_MAC, mac_address)}, @@ -42,12 +40,6 @@ async def get_device_info(hass, entry): "serial_number": serial_number, } - # Register device in the device registry - device_registry.async_get_or_create( - config_entry_id=entry_id, - **device_info - ) - # Cache the device info and data _device_cache[entry_id] = { 'device_info': device_info, @@ -55,3 +47,17 @@ async def get_device_info(hass, entry): } return device_info, data + +async def register_device(hass, entry): + entry_id = entry.entry_id + + # Create a device entry with fetched data + device_registry = async_get_device_registry(hass) + + device_info, _ = await get_device_info(hass, entry) + + # Register device in the device registry + device_registry.async_get_or_create( + config_entry_id=entry_id, + **device_info + ) \ No newline at end of file From 5f4d34d55c0842ee229adb18ed1ed985e6dcdac6 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Wed, 28 Aug 2024 21:02:47 +0200 Subject: [PATCH 15/33] Add device cache expiry --- custom_components/hass_pontos/device.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/custom_components/hass_pontos/device.py b/custom_components/hass_pontos/device.py index b85b41e..b14745b 100644 --- a/custom_components/hass_pontos/device.py +++ b/custom_components/hass_pontos/device.py @@ -1,8 +1,9 @@ import logging +import time from homeassistant.helpers.device_registry import async_get as async_get_device_registry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from .const import DOMAIN, URL_LIST, CONF_IP_ADDRESS +from .const import DOMAIN, URL_LIST, CONF_IP_ADDRESS, FETCH_INTERVAL from .utils import fetch_data LOGGER = logging.getLogger(__name__) @@ -14,10 +15,15 @@ async def get_device_info(hass, entry): entry_id = entry.entry_id ip_address = entry.data[CONF_IP_ADDRESS] - # Check if the data is already cached + # Check if the data is already cached and not expired if entry_id in _device_cache: - LOGGER.debug(f"Using cached data for device {entry_id}") - return _device_cache[entry_id]['device_info'], _device_cache[entry_id]['data'] + cache_entry = _device_cache[entry_id] + cache_age = time.time() - cache_entry['timestamp'] + if cache_age < FETCH_INTERVAL.total_seconds(): + LOGGER.debug(f"Using cached data for device {entry_id} (age: {cache_age} seconds)") + return cache_entry['device_info'], cache_entry['data'] + else: + LOGGER.debug(f"Cache expired for device {entry_id} (age: {cache_age} seconds)") LOGGER.debug(f"Fetching data for device {entry_id} from the device") @@ -43,7 +49,8 @@ async def get_device_info(hass, entry): # Cache the device info and data _device_cache[entry_id] = { 'device_info': device_info, - 'data': data + 'data': data, + 'timestamp': time.time() } return device_info, data From 6fdd069ffe284ebe297febf4e8c7f4892b7d02b9 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Thu, 29 Aug 2024 20:00:52 +0200 Subject: [PATCH 16/33] Create entity names based on device name --- custom_components/hass_pontos/button.py | 33 +++++++++++++++++-------- custom_components/hass_pontos/device.py | 1 - custom_components/hass_pontos/sensor.py | 6 ++--- custom_components/hass_pontos/valve.py | 10 ++++---- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/custom_components/hass_pontos/button.py b/custom_components/hass_pontos/button.py index a81fc7d..daf13e3 100644 --- a/custom_components/hass_pontos/button.py +++ b/custom_components/hass_pontos/button.py @@ -3,27 +3,30 @@ from homeassistant.components.button import ButtonEntity from homeassistant.helpers.entity import Entity from .const import DOMAIN +from .device import get_device_info LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up the button entity for the Pontos Base device.""" - async_add_entities([PontosClearAlarmsButton(hass, entry)], True) + # Fetch device info + device_info, _ = await get_device_info(hass, entry) + + # Instantiate button + reset_button = PontosClearAlarmsButton(hass, entry, device_info) + + # Add entities + async_add_entities([reset_button], True) class PontosClearAlarmsButton(ButtonEntity): """Button to clear alarms on the Pontos Base device.""" - def __init__(self, hass, entry): + def __init__(self, hass, entry, device_info): """Initialize the button.""" self._hass = hass self._entry = entry - self._attr_name = "Clear Alarms" - self._attr_unique_id = f"{entry.entry_id}_clear_alarms" - self._attr_device_info = { - "identifiers": {(DOMAIN, "pontos_base")}, - "name": "Pontos Base", - "manufacturer": "Hansgrohe", - } + self._attr_name = f"{device_info['name']} Clear Alarms" + self._attr_unique_id = f"{device_info['serial_number']}_clear_alarms" + self._attr_device_id = device_info['identifiers'] async def async_press(self): """Handle the button press to clear alarms.""" @@ -33,3 +36,13 @@ async def async_press(self): "clear_alarms", # Assuming the service name for clearing alarms is "clear_alarms" service_data={"entry_id": self._entry.entry_id} ) + + @property + def unique_id(self): + return self._attr_unique_id + + @property + def device_info(self): + return { + "identifiers": self._attr_device_id, + } \ No newline at end of file diff --git a/custom_components/hass_pontos/device.py b/custom_components/hass_pontos/device.py index b14745b..6ad744a 100644 --- a/custom_components/hass_pontos/device.py +++ b/custom_components/hass_pontos/device.py @@ -60,7 +60,6 @@ async def register_device(hass, entry): # Create a device entry with fetched data device_registry = async_get_device_registry(hass) - device_info, _ = await get_device_info(hass, entry) # Register device in the device registry diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 003e3a8..47401a1 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -32,7 +32,7 @@ async def update_data(_): class PontosSensor(SensorEntity): def __init__(self, sensor_config, device_info): self._data = None - self._attr_name = f"Pontos {sensor_config['name']}" + self._attr_name = f"{device_info['name']} {sensor_config['name']}" self._endpoint = sensor_config['endpoint'] self._attr_native_unit_of_measurement = sensor_config.get('unit', None) self._attr_device_class = sensor_config.get('device_class', None) @@ -41,7 +41,7 @@ def __init__(self, sensor_config, device_info): self._code_dict = sensor_config.get('code_dict', None) self._scale = sensor_config.get('scale', None) self._attr_unique_id = f"pontos_{sensor_config['name']}" - self._device_id = device_info['identifiers'] + self._attr_device_id = device_info['identifiers'] def set_data(self, data): self._data = data @@ -54,7 +54,7 @@ def unique_id(self): @property def device_info(self): return { - "identifiers": self._device_id, + "identifiers": self._attr_device_id, } @property diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index bf055f6..7d150ba 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -11,7 +11,7 @@ async def async_setup_entry(hass, entry, async_add_entities): device_info, _ = await get_device_info(hass, entry) # Explicitly instantiate the PontosValve entity - valve_entity = PontosValve(hass, entry, device_info['identifiers']) + valve_entity = PontosValve(hass, entry, device_info) # Add the entity to Home Assistant async_add_entities([valve_entity], True) @@ -22,16 +22,16 @@ async def async_setup_entry(hass, entry, async_add_entities): class PontosValve(ValveEntity): """Representation of the Pontos Valve entity.""" - def __init__(self, hass, entry, device_id): + def __init__(self, hass, entry, device_info): """Initialize the Pontos Valve.""" self._hass = hass self._entry = entry - self._attr_unique_id = "pontos_valve" - self._attr_name = "Pontos Valve" + self._attr_name = f"{device_info['name']} Water Supply" + self._attr_unique_id = f"{device_info['serial_number']}_water_supply" self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER self._state = None # Initial state will be set during the first update - self._device_id = device_id + self._device_id = device_info['identifiers'] @property def state(self): From 54167ce237d90c417ba34a5491212243a4a6b8bd Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Thu, 29 Aug 2024 20:07:02 +0200 Subject: [PATCH 17/33] Text formatting --- custom_components/hass_pontos/button.py | 2 +- custom_components/hass_pontos/services.yaml | 6 +++--- custom_components/hass_pontos/valve.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/hass_pontos/button.py b/custom_components/hass_pontos/button.py index daf13e3..0caab4b 100644 --- a/custom_components/hass_pontos/button.py +++ b/custom_components/hass_pontos/button.py @@ -24,7 +24,7 @@ def __init__(self, hass, entry, device_info): """Initialize the button.""" self._hass = hass self._entry = entry - self._attr_name = f"{device_info['name']} Clear Alarms" + self._attr_name = f"{device_info['name']} Clear alarms" self._attr_unique_id = f"{device_info['serial_number']}_clear_alarms" self._attr_device_id = device_info['identifiers'] diff --git a/custom_components/hass_pontos/services.yaml b/custom_components/hass_pontos/services.yaml index 6fa33c8..c02d9a4 100644 --- a/custom_components/hass_pontos/services.yaml +++ b/custom_components/hass_pontos/services.yaml @@ -1,14 +1,14 @@ open_valve: name: Open Valve - description: Opens the valve. + description: Opens the valve fields: {} close_valve: name: Close Valve - description: Closes the valve. + description: Closes the valve fields: {} clear_alarm: name: Clear Alarms - description: Clears any active alarms. + description: Clears any active alarms fields: {} diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 7d150ba..360a262 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -26,7 +26,7 @@ def __init__(self, hass, entry, device_info): """Initialize the Pontos Valve.""" self._hass = hass self._entry = entry - self._attr_name = f"{device_info['name']} Water Supply" + self._attr_name = f"{device_info['name']} Water supply" self._attr_unique_id = f"{device_info['serial_number']}_water_supply" self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER From c2d601fc8a137c66d568df899e6e95bb25444dca Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Thu, 29 Aug 2024 21:30:16 +0200 Subject: [PATCH 18/33] More refactoring --- custom_components/hass_pontos/button.py | 4 ++-- custom_components/hass_pontos/const.py | 2 +- custom_components/hass_pontos/sensor.py | 6 +++--- custom_components/hass_pontos/valve.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/hass_pontos/button.py b/custom_components/hass_pontos/button.py index 0caab4b..6547cf4 100644 --- a/custom_components/hass_pontos/button.py +++ b/custom_components/hass_pontos/button.py @@ -26,7 +26,7 @@ def __init__(self, hass, entry, device_info): self._entry = entry self._attr_name = f"{device_info['name']} Clear alarms" self._attr_unique_id = f"{device_info['serial_number']}_clear_alarms" - self._attr_device_id = device_info['identifiers'] + self._device_info = device_info async def async_press(self): """Handle the button press to clear alarms.""" @@ -44,5 +44,5 @@ def unique_id(self): @property def device_info(self): return { - "identifiers": self._attr_device_id, + "identifiers": self._device_info['identifiers'], } \ No newline at end of file diff --git a/custom_components/hass_pontos/const.py b/custom_components/hass_pontos/const.py index c711b09..5868f82 100644 --- a/custom_components/hass_pontos/const.py +++ b/custom_components/hass_pontos/const.py @@ -175,4 +175,4 @@ }, } -VALVE_STATUS_SENSOR = "pontos_valve_status" \ No newline at end of file +VALVE_STATUS_SENSOR = "sensor.pontos_base_valve_status" \ No newline at end of file diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 47401a1..65e9446 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -40,8 +40,8 @@ def __init__(self, sensor_config, device_info): self._format_dict = sensor_config.get('format_dict', None) self._code_dict = sensor_config.get('code_dict', None) self._scale = sensor_config.get('scale', None) - self._attr_unique_id = f"pontos_{sensor_config['name']}" - self._attr_device_id = device_info['identifiers'] + self._attr_unique_id = f"{device_info['serial_number']}_{sensor_config['name']}" + self._device_info = device_info def set_data(self, data): self._data = data @@ -54,7 +54,7 @@ def unique_id(self): @property def device_info(self): return { - "identifiers": self._attr_device_id, + "identifiers": self._device_info['identifiers'], } @property diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 360a262..41469a9 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -31,7 +31,7 @@ def __init__(self, hass, entry, device_info): self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER self._state = None # Initial state will be set during the first update - self._device_id = device_info['identifiers'] + self._device_info = device_info @property def state(self): @@ -52,7 +52,7 @@ def unique_id(self): def device_info(self): """Return device info to link this entity with the device.""" return { - "identifiers": self._device_id, + "identifiers": self._device_info['identifiers'], } def open_valve(self) -> None: @@ -83,7 +83,7 @@ async def async_close(self) -> None: async def async_update(self, time_event=None): """Update the valve's state from the valve status sensor.""" - sensor_entity_id = f"sensor.{VALVE_STATUS_SENSOR}" + sensor_entity_id = f"{VALVE_STATUS_SENSOR}" # Fetch the state of the sensor sensor_state = self._hass.states.get(sensor_entity_id) From c178713f4f1fc663cb40fdc55478043a5908148c Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Fri, 30 Aug 2024 02:52:15 +0200 Subject: [PATCH 19/33] Moved parsing back to sensor.py Parsing is depenent on the PontosSensor class, so not really usable elsewhere --- custom_components/hass_pontos/sensor.py | 47 +++++++++++++++++++------ custom_components/hass_pontos/utils.py | 24 ------------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 65e9446..7320d04 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -2,7 +2,7 @@ from homeassistant.helpers.event import async_track_time_interval import logging -from .utils import fetch_data, parse_data +from .utils import fetch_data from .device import get_device_info from .const import CONF_IP_ADDRESS, SENSOR_DETAILS, FETCH_INTERVAL, URL_LIST @@ -18,20 +18,20 @@ async def async_setup_entry(hass, entry, async_add_entities): # Update data so sensors is available immediately for sensor in sensors: - sensor.set_data(parse_data(data, sensor)) + sensor.set_state(sensor.parse_data(data)) - # Function to fetch new data and update all sensors - async def update_data(_): - data = await fetch_data(ip_address, URL_LIST) + # Function to fetch new state and update all sensors + async def update_state(_): + state = await fetch_data(ip_address, URL_LIST) for sensor in sensors: - sensor.set_data(parse_data(data, sensor)) + sensor.set_state(sensor.parse_data(data)) # Schedule updates using the fetch interval - async_track_time_interval(hass, update_data, FETCH_INTERVAL) + async_track_time_interval(hass, update_state, FETCH_INTERVAL) class PontosSensor(SensorEntity): def __init__(self, sensor_config, device_info): - self._data = None + self._state = None self._attr_name = f"{device_info['name']} {sensor_config['name']}" self._endpoint = sensor_config['endpoint'] self._attr_native_unit_of_measurement = sensor_config.get('unit', None) @@ -43,8 +43,8 @@ def __init__(self, sensor_config, device_info): self._attr_unique_id = f"{device_info['serial_number']}_{sensor_config['name']}" self._device_info = device_info - def set_data(self, data): - self._data = data + def set_state(self, state): + self._state = state self.async_write_ha_state() @property @@ -59,4 +59,29 @@ def device_info(self): @property def state(self): - return self._data + return self._state + + # Parsing sensor data + def parse_data(self, data): + """Process, format, and validate sensor data.""" + if data is None: + return None + _data = data.get(self._endpoint, None) + + # Apply format replacements if format_dict is present + if self._format_dict is not None and _data is not None: + for old, new in self._format_dict.items(): + _data = _data.replace(old, new) + + # Translate alarm codes if code_dict is present + if self._code_dict is not None and _data is not None: + _data = self._code_dict.get(_data, _data) + + # Scale sensor data if scale is present + if self._scale is not None and _data is not None: + try: + _data = float(_data) * self._scale + except (ValueError, TypeError): + pass + + return _data \ No newline at end of file diff --git a/custom_components/hass_pontos/utils.py b/custom_components/hass_pontos/utils.py index bd1b006..b3a1b87 100644 --- a/custom_components/hass_pontos/utils.py +++ b/custom_components/hass_pontos/utils.py @@ -15,27 +15,3 @@ async def fetch_data(ip, url_list): LOGGER.error(f"Failed to fetch data: HTTP {response.status}") return data -# Parsing sensor data -def parse_data(data, sensor): - """Process, format, and validate sensor data.""" - if data is None: - return None - _data = data.get(sensor._endpoint, None) - - # Apply format replacements if format_dict is present - if sensor._format_dict is not None and _data is not None: - for old, new in sensor._format_dict.items(): - _data = _data.replace(old, new) - - # Translate alarm codes if code_dict is present - if sensor._code_dict is not None and _data is not None: - _data = sensor._code_dict.get(_data, _data) - - # Scale sensor data if scale is present - if sensor._scale is not None and _data is not None: - try: - _data = float(_data) * sensor._scale - except (ValueError, TypeError): - pass - - return _data From 0d42564775fc9844b58d46607dd3f9f152b8915d Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Fri, 30 Aug 2024 03:42:34 +0200 Subject: [PATCH 20/33] Fix regression where sensors did not update --- custom_components/hass_pontos/sensor.py | 29 +++++++++++++------------ custom_components/hass_pontos/utils.py | 1 - 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 7320d04..93543e1 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -18,20 +18,20 @@ async def async_setup_entry(hass, entry, async_add_entities): # Update data so sensors is available immediately for sensor in sensors: - sensor.set_state(sensor.parse_data(data)) + sensor.parse_data(data) - # Function to fetch new state and update all sensors - async def update_state(_): - state = await fetch_data(ip_address, URL_LIST) + # Function to fetch new data and update all sensors + async def update_data(_): + data = await fetch_data(ip_address, URL_LIST) for sensor in sensors: - sensor.set_state(sensor.parse_data(data)) + sensor.parse_data(data) # Schedule updates using the fetch interval - async_track_time_interval(hass, update_state, FETCH_INTERVAL) + async_track_time_interval(hass, update_data, FETCH_INTERVAL) class PontosSensor(SensorEntity): def __init__(self, sensor_config, device_info): - self._state = None + self._data = None self._attr_name = f"{device_info['name']} {sensor_config['name']}" self._endpoint = sensor_config['endpoint'] self._attr_native_unit_of_measurement = sensor_config.get('unit', None) @@ -43,8 +43,8 @@ def __init__(self, sensor_config, device_info): self._attr_unique_id = f"{device_info['serial_number']}_{sensor_config['name']}" self._device_info = device_info - def set_state(self, state): - self._state = state + def set_data(self, data): + self._data = data self.async_write_ha_state() @property @@ -59,9 +59,9 @@ def device_info(self): @property def state(self): - return self._state - - # Parsing sensor data + return self._data + + # Parsing and updating sensor data def parse_data(self, data): """Process, format, and validate sensor data.""" if data is None: @@ -83,5 +83,6 @@ def parse_data(self, data): _data = float(_data) * self._scale except (ValueError, TypeError): pass - - return _data \ No newline at end of file + + # Update sensor data + self.set_data(_data) diff --git a/custom_components/hass_pontos/utils.py b/custom_components/hass_pontos/utils.py index b3a1b87..ae55022 100644 --- a/custom_components/hass_pontos/utils.py +++ b/custom_components/hass_pontos/utils.py @@ -14,4 +14,3 @@ async def fetch_data(ip, url_list): else: LOGGER.error(f"Failed to fetch data: HTTP {response.status}") return data - From 9232d011838e5b8c6bae9a94dd7d584ff5e5fee9 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Fri, 30 Aug 2024 04:13:42 +0200 Subject: [PATCH 21/33] Add event listener to valve entity --- custom_components/hass_pontos/valve.py | 55 +++++++++++++++----------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 41469a9..1208bf0 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -1,23 +1,28 @@ import logging from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveDeviceClass -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.core import callback, Event from .device import get_device_info -from .const import DOMAIN, VALVE_STATUS_SENSOR, FETCH_INTERVAL +from .const import DOMAIN, VALVE_STATUS_SENSOR LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up the valve entity.""" - device_info, _ = await get_device_info(hass, entry) + device_info, data = await get_device_info(hass, entry) - # Explicitly instantiate the PontosValve entity + # Fetch initial state from the sensor entity + sensor_state = hass.states.get(VALVE_STATUS_SENSOR) + initial_state = sensor_state.state if sensor_state else None + + # Instantiate the PontosValve entity valve_entity = PontosValve(hass, entry, device_info) # Add the entity to Home Assistant async_add_entities([valve_entity], True) - # Schedule periodic updates to check the valve state - async_track_time_interval(hass, valve_entity.async_update, FETCH_INTERVAL) + # Set initial state + valve_entity.set_state(initial_state) class PontosValve(ValveEntity): """Representation of the Pontos Valve entity.""" @@ -30,9 +35,28 @@ def __init__(self, hass, entry, device_info): self._attr_unique_id = f"{device_info['serial_number']}_water_supply" self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER - self._state = None # Initial state will be set during the first update + self._state = None self._device_info = device_info + def set_state(self, state): + self._state = state + self.async_write_ha_state() + + async def async_added_to_hass(self): + """When entity is added to hass.""" + await super().async_added_to_hass() + async_track_state_change_event( + self.hass, + VALVE_STATUS_SENSOR, + self._sensor_state_changed + ) + + @callback + def _sensor_state_changed(self, event: Event) -> None: + new_state = event.data.get('new_state') + if new_state is not None: + self.set_state(new_state.state) + @property def state(self): """Return the current state of the valve.""" @@ -80,20 +104,3 @@ async def async_close(self) -> None: "close_valve", service_data={"entry_id": self._entry.entry_id} ) - - async def async_update(self, time_event=None): - """Update the valve's state from the valve status sensor.""" - sensor_entity_id = f"{VALVE_STATUS_SENSOR}" - - # Fetch the state of the sensor - sensor_state = self._hass.states.get(sensor_entity_id) - - if sensor_state: - self._state = sensor_state.state - else: - LOGGER.error(f"Could not find sensor state for {sensor_entity_id}") - self._state = None # If sensor state is not found, set to None - - # Write the state to Home Assistant - if self.entity_id: - self.async_write_ha_state() From d23ef4bb5c547a609752091d3e01ae57e97d5572 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 01:29:54 +0200 Subject: [PATCH 22/33] Look up unique id for valve status sensor --- custom_components/hass_pontos/button.py | 4 +-- custom_components/hass_pontos/sensor.py | 3 +- custom_components/hass_pontos/valve.py | 37 +++++++++++++++++-------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/custom_components/hass_pontos/button.py b/custom_components/hass_pontos/button.py index 6547cf4..aca8e18 100644 --- a/custom_components/hass_pontos/button.py +++ b/custom_components/hass_pontos/button.py @@ -1,7 +1,7 @@ # custom_components/hass_pontos/button.py import logging from homeassistant.components.button import ButtonEntity -from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from .const import DOMAIN from .device import get_device_info @@ -25,7 +25,7 @@ def __init__(self, hass, entry, device_info): self._hass = hass self._entry = entry self._attr_name = f"{device_info['name']} Clear alarms" - self._attr_unique_id = f"{device_info['serial_number']}_clear_alarms" + self._attr_unique_id = slugify(f"{device_info['serial_number']}_clear_alarms") self._device_info = device_info async def async_press(self): diff --git a/custom_components/hass_pontos/sensor.py b/custom_components/hass_pontos/sensor.py index 93543e1..b98f3bd 100644 --- a/custom_components/hass_pontos/sensor.py +++ b/custom_components/hass_pontos/sensor.py @@ -1,5 +1,6 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import slugify import logging from .utils import fetch_data @@ -40,7 +41,7 @@ def __init__(self, sensor_config, device_info): self._format_dict = sensor_config.get('format_dict', None) self._code_dict = sensor_config.get('code_dict', None) self._scale = sensor_config.get('scale', None) - self._attr_unique_id = f"{device_info['serial_number']}_{sensor_config['name']}" + self._attr_unique_id = slugify(f"{device_info['serial_number']}_{sensor_config['name']}") self._device_info = device_info def set_data(self, data): diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 1208bf0..293dfd0 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -1,9 +1,11 @@ import logging from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveDeviceClass from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers import entity_registry as er from homeassistant.core import callback, Event +from homeassistant.util import slugify from .device import get_device_info -from .const import DOMAIN, VALVE_STATUS_SENSOR +from .const import DOMAIN LOGGER = logging.getLogger(__name__) @@ -11,12 +13,15 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the valve entity.""" device_info, data = await get_device_info(hass, entry) - # Fetch initial state from the sensor entity - sensor_state = hass.states.get(VALVE_STATUS_SENSOR) + # Fetch initial state from the sensor entity using its unique ID + sensor_unique_id = slugify(f"{device_info['serial_number']}_valve_status") + entity_registry = er.async_get(hass) + sensor_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, sensor_unique_id) + sensor_state = hass.states.get(sensor_entity_id) initial_state = sensor_state.state if sensor_state else None # Instantiate the PontosValve entity - valve_entity = PontosValve(hass, entry, device_info) + valve_entity = PontosValve(hass, entry, device_info, sensor_unique_id) # Add the entity to Home Assistant async_add_entities([valve_entity], True) @@ -27,12 +32,13 @@ async def async_setup_entry(hass, entry, async_add_entities): class PontosValve(ValveEntity): """Representation of the Pontos Valve entity.""" - def __init__(self, hass, entry, device_info): + def __init__(self, hass, entry, device_info, sensor_unique_id = None): """Initialize the Pontos Valve.""" self._hass = hass self._entry = entry + self._sensor_unique_id = sensor_unique_id self._attr_name = f"{device_info['name']} Water supply" - self._attr_unique_id = f"{device_info['serial_number']}_water_supply" + self._attr_unique_id = slugify(f"{device_info['serial_number']}_water_supply") self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER self._state = None @@ -45,11 +51,20 @@ def set_state(self, state): async def async_added_to_hass(self): """When entity is added to hass.""" await super().async_added_to_hass() - async_track_state_change_event( - self.hass, - VALVE_STATUS_SENSOR, - self._sensor_state_changed - ) + + # Find the sensor entity ID based on the unique ID + entity_registry = er.async_get(self.hass) + sensor_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, self._sensor_unique_id) + + if sensor_entity_id: + LOGGER.debug(f"Registering state change listener for {sensor_entity_id}") + async_track_state_change_event( + self.hass, + sensor_entity_id, + self._sensor_state_changed + ) + else: + LOGGER.error(f"Sensor with unique ID {self._sensor_unique_id} not found") @callback def _sensor_state_changed(self, event: Event) -> None: From 1cc84b582cb51a473bf0cc82758d4c3c9d1f0b43 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 01:30:19 +0200 Subject: [PATCH 23/33] Version bump --- custom_components/hass_pontos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/hass_pontos/manifest.json b/custom_components/hass_pontos/manifest.json index baa0fb4..ca9f591 100644 --- a/custom_components/hass_pontos/manifest.json +++ b/custom_components/hass_pontos/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/sangvikh/hass-pontos/issues", "requirements": [], - "version": "1.1.0" + "version": "2.0.0" } From 2d6cbab533f9ed1076fd23656d993c2c1f09640f Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 02:13:31 +0200 Subject: [PATCH 24/33] Refactor valve.py --- custom_components/hass_pontos/const.py | 2 -- custom_components/hass_pontos/valve.py | 40 ++++++++++++-------------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/custom_components/hass_pontos/const.py b/custom_components/hass_pontos/const.py index 5868f82..1274f2e 100644 --- a/custom_components/hass_pontos/const.py +++ b/custom_components/hass_pontos/const.py @@ -174,5 +174,3 @@ "endpoint": "clr/ala" }, } - -VALVE_STATUS_SENSOR = "sensor.pontos_base_valve_status" \ No newline at end of file diff --git a/custom_components/hass_pontos/valve.py b/custom_components/hass_pontos/valve.py index 293dfd0..85d5559 100644 --- a/custom_components/hass_pontos/valve.py +++ b/custom_components/hass_pontos/valve.py @@ -2,61 +2,54 @@ from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveDeviceClass from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers import entity_registry as er -from homeassistant.core import callback, Event from homeassistant.util import slugify +from homeassistant.core import callback, Event from .device import get_device_info from .const import DOMAIN LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): - """Set up the valve entity.""" + # Get device info device_info, data = await get_device_info(hass, entry) - # Fetch initial state from the sensor entity using its unique ID - sensor_unique_id = slugify(f"{device_info['serial_number']}_valve_status") - entity_registry = er.async_get(hass) - sensor_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, sensor_unique_id) - sensor_state = hass.states.get(sensor_entity_id) - initial_state = sensor_state.state if sensor_state else None - # Instantiate the PontosValve entity - valve_entity = PontosValve(hass, entry, device_info, sensor_unique_id) + valve_entity = PontosValve(hass, entry, device_info) # Add the entity to Home Assistant async_add_entities([valve_entity], True) - # Set initial state - valve_entity.set_state(initial_state) - class PontosValve(ValveEntity): """Representation of the Pontos Valve entity.""" - def __init__(self, hass, entry, device_info, sensor_unique_id = None): + def __init__(self, hass, entry, device_info): """Initialize the Pontos Valve.""" self._hass = hass self._entry = entry - self._sensor_unique_id = sensor_unique_id self._attr_name = f"{device_info['name']} Water supply" self._attr_unique_id = slugify(f"{device_info['serial_number']}_water_supply") self._attr_reports_position = False self._attr_device_class = ValveDeviceClass.WATER self._state = None self._device_info = device_info - - def set_state(self, state): - self._state = state - self.async_write_ha_state() + self._sensor_unique_id = slugify(f"{device_info['serial_number']}_valve_status") async def async_added_to_hass(self): """When entity is added to hass.""" await super().async_added_to_hass() - # Find the sensor entity ID based on the unique ID + # Get the entity ID of the sensor using the unique ID entity_registry = er.async_get(self.hass) sensor_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, self._sensor_unique_id) - + + # Fetch the initial state from the sensor entity if sensor_entity_id: + sensor_state = self.hass.states.get(sensor_entity_id) + initial_state = sensor_state.state if sensor_state else None + LOGGER.debug(f"Fetched initial valve state from sensor: {initial_state}") + self.set_state(initial_state) + + # Register state change listener LOGGER.debug(f"Registering state change listener for {sensor_entity_id}") async_track_state_change_event( self.hass, @@ -66,6 +59,11 @@ async def async_added_to_hass(self): else: LOGGER.error(f"Sensor with unique ID {self._sensor_unique_id} not found") + def set_state(self, state): + """Set the valve state and update Home Assistant.""" + self._state = state + self.async_write_ha_state() + @callback def _sensor_state_changed(self, event: Event) -> None: new_state = event.data.get('new_state') From 2fdd41aa90a7076b7adb003e78c301b552de1b86 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 03:05:28 +0200 Subject: [PATCH 25/33] Add services for selecting profile --- custom_components/hass_pontos/const.py | 4 ++ custom_components/hass_pontos/services.py | 49 +++++++++++++-------- custom_components/hass_pontos/services.yaml | 9 ++++ 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/custom_components/hass_pontos/const.py b/custom_components/hass_pontos/const.py index 1274f2e..e83ce9f 100644 --- a/custom_components/hass_pontos/const.py +++ b/custom_components/hass_pontos/const.py @@ -173,4 +173,8 @@ "name": "Clear alarms", "endpoint": "clr/ala" }, + "set_profile": { + "name": "Set Profile", + "endpoint": "set/prf/{profile_number}" + }, } diff --git a/custom_components/hass_pontos/services.py b/custom_components/hass_pontos/services.py index f9089a4..e0ef9d8 100644 --- a/custom_components/hass_pontos/services.py +++ b/custom_components/hass_pontos/services.py @@ -5,29 +5,40 @@ LOGGER = logging.getLogger(__name__) -async def register_services(hass): - async def async_send_command(ip_address, endpoint): - """Helper function to send commands to the device.""" - url = BASE_URL.format(ip=ip_address) + endpoint - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - LOGGER.info(f"Successfully sent command to {endpoint}.") - else: - LOGGER.error(f"Failed to send command to {endpoint}: HTTP {response.status}") +async def async_send_command(hass, ip_address, endpoint, data=None): + """Helper function to send commands to the device.""" + if data: + # Replace placeholders in the endpoint with actual data + endpoint = endpoint.format(**data) + + # Construct the full URL + url = BASE_URL.format(ip=ip_address) + endpoint + + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + LOGGER.info(f"Successfully sent command to {endpoint}.") + else: + LOGGER.error(f"Failed to send command to {endpoint}: HTTP {response.status}") - async def async_service_handler(call, service_name): - """General service handler to handle different services.""" - for entry_data in hass.data[DOMAIN].values(): - ip_address = entry_data.get("ip_address") - endpoint = SERVICES[service_name]["endpoint"] - await async_send_command(ip_address, endpoint) +async def async_service_handler(hass, call, service_name): + """General service handler to handle different services.""" + for entry_data in hass.data[DOMAIN].values(): + ip_address = entry_data.get("ip_address") + + # Extract any additional data from the service call + data = call.data + + # Handle the service call with dynamic data if provided + endpoint = SERVICES[service_name]["endpoint"] + await async_send_command(hass, ip_address, endpoint, data) - # Dynamically register all services defined in SERVICES +async def register_services(hass): + """Register all custom services.""" + # Register each service dynamically based on the SERVICES dictionary for service_name in SERVICES: async def service_handler(call, service_name=service_name): - await async_service_handler(call, service_name) + await async_service_handler(hass, call, service_name) hass.services.async_register( DOMAIN, diff --git a/custom_components/hass_pontos/services.yaml b/custom_components/hass_pontos/services.yaml index c02d9a4..d21e03e 100644 --- a/custom_components/hass_pontos/services.yaml +++ b/custom_components/hass_pontos/services.yaml @@ -12,3 +12,12 @@ clear_alarm: name: Clear Alarms description: Clears any active alarms fields: {} + +set_profile: + name: "Set Profile" + description: "Set the water valve profile" + fields: + profile_number: + description: "The profile number to set (e.g., 1, 2, 3, ...)" + example: 1 + required: true From 324e2e741702588c001649d8a76ce641d2ef8730 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 03:26:29 +0200 Subject: [PATCH 26/33] Add profile selection --- custom_components/hass_pontos/__init__.py | 2 +- custom_components/hass_pontos/select.py | 69 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 custom_components/hass_pontos/select.py diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index 065cd4b..6f85748 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -20,7 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Register entities hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button', 'valve']) + hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button', 'valve', 'select']) ) return True diff --git a/custom_components/hass_pontos/select.py b/custom_components/hass_pontos/select.py new file mode 100644 index 0000000..4f61bc0 --- /dev/null +++ b/custom_components/hass_pontos/select.py @@ -0,0 +1,69 @@ +import logging +from homeassistant.components.select import SelectEntity +from homeassistant.core import callback +from .device import get_device_info +from .const import DOMAIN, PROFILE_CODES + +LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the custom profile select entity.""" + device_info, data = await get_device_info(hass, entry) + select_entity = PontosProfileSelect(hass, entry, device_info) + async_add_entities([select_entity], True) + +class PontosProfileSelect(SelectEntity): + """Representation of a Select entity for setting profiles.""" + + def __init__(self, hass, entry, device_info): + """Initialize the profile select entity.""" + self._hass = hass + self._entry = entry + self._attr_name = f"{device_info['name']} Profile" + self._attr_unique_id = f"{device_info['serial_number']}_profile_select" + self._attr_options = [name for name in PROFILE_CODES.values() if name != "not defined"] + self._attr_current_option = None + self._device_info = device_info + + async def async_added_to_hass(self): + """When entity is added to hass.""" + await super().async_added_to_hass() + # Retrieve the current profile from the device and set it as the current option + current_profile = await self.get_current_profile() + self._attr_current_option = self.map_profile_number_to_name(current_profile) + self.async_write_ha_state() + + async def async_select_option(self, option: str): + """Handle the user selecting an option.""" + LOGGER.info(f"Setting profile to {option}") + profile_number = self.map_profile_name_to_number(option) + await self._hass.services.async_call( + DOMAIN, + "set_profile", + service_data={"profile_number": profile_number, "ip_address": self._entry.data["ip_address"]} + ) + self._attr_current_option = option + self.async_write_ha_state() + + @property + def device_info(self): + """Return device info to link this entity with the device.""" + return { + "identifiers": self._device_info['identifiers'], + } + + async def get_current_profile(self): + """Fetch the current profile from the device.""" + # Add your logic here to fetch the current profile from the device + return 1 # Example return value + + def map_profile_number_to_name(self, profile_number): + """Map profile number to profile name.""" + return PROFILE_CODES.get(str(profile_number), "Unknown") + + def map_profile_name_to_number(self, profile_name): + """Map profile name to profile number.""" + for number, name in PROFILE_CODES.items(): + if name == profile_name: + return int(number) + return None From 015f2eaa80b7664bfd7090f55465bda0ad372134 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 03:58:35 +0200 Subject: [PATCH 27/33] Handle empty profile names --- custom_components/hass_pontos/select.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/hass_pontos/select.py b/custom_components/hass_pontos/select.py index 4f61bc0..a75b6d2 100644 --- a/custom_components/hass_pontos/select.py +++ b/custom_components/hass_pontos/select.py @@ -21,7 +21,11 @@ def __init__(self, hass, entry, device_info): self._entry = entry self._attr_name = f"{device_info['name']} Profile" self._attr_unique_id = f"{device_info['serial_number']}_profile_select" - self._attr_options = [name for name in PROFILE_CODES.values() if name != "not defined"] + self._attr_options = [ + name if name else "Not Defined" + for name in PROFILE_CODES.values() + if name and name != "not defined" + ] self._attr_current_option = None self._device_info = device_info @@ -59,7 +63,7 @@ async def get_current_profile(self): def map_profile_number_to_name(self, profile_number): """Map profile number to profile name.""" - return PROFILE_CODES.get(str(profile_number), "Unknown") + return PROFILE_CODES.get(str(profile_number), "not defined") def map_profile_name_to_number(self, profile_name): """Map profile name to profile number.""" From 75724d08e527a3f689ec0014fb9ba721b9d05013 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 04:00:10 +0200 Subject: [PATCH 28/33] update readme --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 2523d14..f27a512 100644 --- a/readme.md +++ b/readme.md @@ -41,7 +41,7 @@ The sensors and services can be added using restful integration. This is a bit l - [x] Add services - [x] Add water valve button -- [ ] Add profile selection +- [x] Add profile selection - [ ] Read profile names -- [ ] Select active profile +- [x] Select active profile - [ ] Include in HACS \ No newline at end of file From 18a4b400fca59092732fa473c37eeec43a58697a Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 11:23:02 +0200 Subject: [PATCH 29/33] Update profile service yaml --- custom_components/hass_pontos/services.yaml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/hass_pontos/services.yaml b/custom_components/hass_pontos/services.yaml index d21e03e..566fa6b 100644 --- a/custom_components/hass_pontos/services.yaml +++ b/custom_components/hass_pontos/services.yaml @@ -14,10 +14,15 @@ clear_alarm: fields: {} set_profile: - name: "Set Profile" - description: "Set the water valve profile" + name: Set Profile + description: Sets the profile for the device. fields: profile_number: - description: "The profile number to set (e.g., 1, 2, 3, ...)" - example: 1 + name: Profile Number + description: The profile number to set. required: true + selector: + number: + min: 1 + max: 8 + From 24984cc6e1fe94e99486fb3e3f8299b763cc8ce9 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 12:05:25 +0200 Subject: [PATCH 30/33] Add event listener for profile selector state --- custom_components/hass_pontos/select.py | 49 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/custom_components/hass_pontos/select.py b/custom_components/hass_pontos/select.py index a75b6d2..19d0eaa 100644 --- a/custom_components/hass_pontos/select.py +++ b/custom_components/hass_pontos/select.py @@ -1,6 +1,8 @@ import logging from homeassistant.components.select import SelectEntity -from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.core import callback, Event from .device import get_device_info from .const import DOMAIN, PROFILE_CODES @@ -21,6 +23,7 @@ def __init__(self, hass, entry, device_info): self._entry = entry self._attr_name = f"{device_info['name']} Profile" self._attr_unique_id = f"{device_info['serial_number']}_profile_select" + self._sensor_unique_id = f"{device_info['serial_number']}_active_profile" self._attr_options = [ name if name else "Not Defined" for name in PROFILE_CODES.values() @@ -32,9 +35,42 @@ def __init__(self, hass, entry, device_info): async def async_added_to_hass(self): """When entity is added to hass.""" await super().async_added_to_hass() - # Retrieve the current profile from the device and set it as the current option - current_profile = await self.get_current_profile() - self._attr_current_option = self.map_profile_number_to_name(current_profile) + + # Get the entity ID of the sensor using the unique ID + entity_registry = er.async_get(self.hass) + sensor_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, self._sensor_unique_id) + + # Fetch the initial state from the sensor entity + if sensor_entity_id: + sensor_state = self.hass.states.get(sensor_entity_id) + initial_state = sensor_state.state if sensor_state else None + LOGGER.debug(f"Fetched initial profile state from sensor: {initial_state}") + self._attr_current_option = initial_state + self.async_write_ha_state() + + # Register state change listener + LOGGER.debug(f"Registering state change listener for {sensor_entity_id}") + async_track_state_change_event( + self.hass, + sensor_entity_id, + self._sensor_state_changed + ) + else: + LOGGER.error(f"Sensor with unique ID {self._sensor_unique_id} not found") + + @callback + def _sensor_state_changed(self, event: Event) -> None: + """Handle active profile sensor state changes.""" + new_state = event.data.get('new_state') + if new_state is not None: + new_option = self.map_profile_number_to_name(new_state.state) + if new_option != self._attr_current_option: + LOGGER.debug(f"Profile state changed to: {new_option}") + self.set_state(new_option) + + def set_state(self, state): + """Set the valve state and update Home Assistant.""" + self._attr_current_option = state self.async_write_ha_state() async def async_select_option(self, option: str): @@ -56,11 +92,6 @@ def device_info(self): "identifiers": self._device_info['identifiers'], } - async def get_current_profile(self): - """Fetch the current profile from the device.""" - # Add your logic here to fetch the current profile from the device - return 1 # Example return value - def map_profile_number_to_name(self, profile_number): """Map profile number to profile name.""" return PROFILE_CODES.get(str(profile_number), "not defined") From 09fdd2f0fa5f1cc18df81b97e851e2f17d088e32 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 12:45:47 +0200 Subject: [PATCH 31/33] Remove profile number translation in profile selector callback --- custom_components/hass_pontos/select.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/custom_components/hass_pontos/select.py b/custom_components/hass_pontos/select.py index 19d0eaa..f9fac1d 100644 --- a/custom_components/hass_pontos/select.py +++ b/custom_components/hass_pontos/select.py @@ -63,7 +63,7 @@ def _sensor_state_changed(self, event: Event) -> None: """Handle active profile sensor state changes.""" new_state = event.data.get('new_state') if new_state is not None: - new_option = self.map_profile_number_to_name(new_state.state) + new_option = new_state.state if new_option != self._attr_current_option: LOGGER.debug(f"Profile state changed to: {new_option}") self.set_state(new_option) @@ -92,10 +92,6 @@ def device_info(self): "identifiers": self._device_info['identifiers'], } - def map_profile_number_to_name(self, profile_number): - """Map profile number to profile name.""" - return PROFILE_CODES.get(str(profile_number), "not defined") - def map_profile_name_to_number(self, profile_name): """Map profile name to profile number.""" for number, name in PROFILE_CODES.items(): From 73f197ae953df7bb7abe87d48f093f7fc27ef4b6 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 12:46:10 +0200 Subject: [PATCH 32/33] Streamline setting up and taking down platforms --- custom_components/hass_pontos/__init__.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/custom_components/hass_pontos/__init__.py b/custom_components/hass_pontos/__init__.py index 6f85748..f81f890 100644 --- a/custom_components/hass_pontos/__init__.py +++ b/custom_components/hass_pontos/__init__.py @@ -5,6 +5,8 @@ from .device import register_device from .const import DOMAIN +platforms = ['sensor', 'button', 'valve', 'select'] + async def async_setup(hass: HomeAssistant, config: dict): return True @@ -18,16 +20,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Register services await register_services(hass) - # Register entities + # Register entities for each platform hass.async_create_task( - hass.config_entries.async_forward_entry_setups(entry, ['sensor', 'button', 'valve', 'select']) + hass.config_entries.async_forward_entry_setups(entry, platforms) ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - await hass.config_entries.async_forward_entry_unload(entry, 'sensor') - await hass.config_entries.async_forward_entry_unload(entry, 'button') - await hass.config_entries.async_forward_entry_unload(entry, 'valve') - hass.data[DOMAIN].pop(entry.entry_id) - return True + # Unload each platform + unload_ok = all( + await hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in platforms + ) + + # Remove data related to this entry if everything is unloaded successfully + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok \ No newline at end of file From edd966be762c971ae2566134c38c7eb929197c23 Mon Sep 17 00:00:00 2001 From: Harald Sangvik Date: Sun, 1 Sep 2024 13:58:18 +0200 Subject: [PATCH 33/33] Update readme --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index f27a512..583a3d7 100644 --- a/readme.md +++ b/readme.md @@ -43,5 +43,6 @@ The sensors and services can be added using restful integration. This is a bit l - [x] Add water valve button - [x] Add profile selection - [ ] Read profile names +- [ ] Update sensors on service calls - [x] Select active profile - [ ] Include in HACS \ No newline at end of file