From 41c6fc9cdf1931211c1a05a587ffdc49feef9304 Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Wed, 12 Mar 2025 12:13:39 +0200 Subject: [PATCH] moving to device, trying to add api set for water setpoint --- .devcontainer.json | 49 ++++++ config/configuration.yaml | 12 ++ .../ha_rdz_pdc_config/__init__.py | 148 +++++++++++++++--- .../ha_rdz_pdc_config/binary_sensor.py | 12 +- .../ha_rdz_pdc_config/climate.py | 92 +++++++++++ .../ha_rdz_pdc_config/config_flow.py | 0 .../ha_rdz_pdc_config/manifest.json | 0 .../ha_rdz_pdc_config/sensor.py | 27 ++-- .../ha_rdz_pdc_config/translations}/en.json | 0 .../ha_rdz_pdc_config/water_heater.py | 53 +++++++ scripts/develop | 20 +++ scripts/lint | 8 + scripts/setup | 7 + 13 files changed, 386 insertions(+), 42 deletions(-) create mode 100644 .devcontainer.json create mode 100644 config/configuration.yaml rename __init__.py => custom_components/ha_rdz_pdc_config/__init__.py (51%) rename binary_sensor.py => custom_components/ha_rdz_pdc_config/binary_sensor.py (84%) create mode 100644 custom_components/ha_rdz_pdc_config/climate.py rename config_flow.py => custom_components/ha_rdz_pdc_config/config_flow.py (100%) rename manifest.json => custom_components/ha_rdz_pdc_config/manifest.json (100%) rename sensor.py => custom_components/ha_rdz_pdc_config/sensor.py (78%) rename {translations => custom_components/ha_rdz_pdc_config/translations}/en.json (100%) create mode 100644 custom_components/ha_rdz_pdc_config/water_heater.py create mode 100755 scripts/develop create mode 100755 scripts/lint create mode 100755 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..562abd0 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,49 @@ +{ + "name": "ludeeus/integration_blueprint", + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers-extra/features/apt-packages:1": { + "packages": [ + "ffmpeg", + "libturbojpeg0", + "libpcap-dev" + ] + } + } +} \ No newline at end of file diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..ccc8410 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,12 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/homeassistant/ +homeassistant: + debug: true + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.integration_blueprint: debug diff --git a/__init__.py b/custom_components/ha_rdz_pdc_config/__init__.py similarity index 51% rename from __init__.py rename to custom_components/ha_rdz_pdc_config/__init__.py index 974a0df..a54ac51 100644 --- a/__init__.py +++ b/custom_components/ha_rdz_pdc_config/__init__.py @@ -1,26 +1,12 @@ """ -Custom integration for heat pump monitoring via web interface. -Place this code in custom_components/heat_pump_monitor/__init__.py +Custom integration for heat pump monitoring and control via web interface. +Place this code in custom_components/ha_rdz_pdc_config/__init__.py """ -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the Heat Pump Monitor component.""" - hass.data.setdefault(DOMAIN, {}) - return True - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok import asyncio import logging import aiohttp from datetime import timedelta -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_URL, @@ -32,12 +18,31 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) +import voluptuous as vol +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DOMAIN = "ha_rdz_pdc_config" DEFAULT_SCAN_INTERVAL = 3 -PLATFORMS = ["sensor", "binary_sensor"] +# Add climate to the platforms +PLATFORMS = ["sensor", "binary_sensor", "climate", "water_heater"] + +# Service constants +SERVICE_SET_WATER_SETPOINT = "set_water_setpoint" +ATTR_TEMPERATURE = "temperature" +ATTR_DURATION = "duration" + +# Service schema +SET_WATER_SETPOINT_SCHEMA = vol.Schema({ + vol.Required(ATTR_TEMPERATURE): vol.All(vol.Coerce(float), vol.Range(min=35, max=60)), + vol.Optional(ATTR_DURATION, default="forever"): cv.string, +}) + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Heat Pump Monitor component.""" + hass.data.setdefault(DOMAIN, {}) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up heat pump monitor from a config entry.""" @@ -56,9 +61,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + # Register the service + async def set_water_setpoint(call: ServiceCall) -> None: + """Set the water setpoint of the heat pump.""" + temperature = call.data.get(ATTR_TEMPERATURE) + duration = call.data.get(ATTR_DURATION, "forever") + await coordinator.set_water_setpoint(temperature, duration) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_WATER_SETPOINT, + set_water_setpoint, + schema=SET_WATER_SETPOINT_SCHEMA, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + class HeatPumpDataCoordinator(DataUpdateCoordinator): """Class to manage fetching heat pump data.""" @@ -77,6 +103,9 @@ class HeatPumpDataCoordinator(DataUpdateCoordinator): update_interval=update_interval, ) self.url = url + # Extract the base URL by removing the last path segment + parts = url.split('/') + self.base_url = '/'.join(parts[:-1]) async def _async_update_data(self): """Fetch data from heat pump.""" @@ -128,4 +157,85 @@ class HeatPumpDataCoordinator(DataUpdateCoordinator): "alarm_label": status.get("alarm", {}).get("label", ""), } except aiohttp.ClientError as err: - raise UpdateFailed(f"Error communicating with heat pump: {err}") \ No newline at end of file + raise UpdateFailed(f"Error communicating with heat pump: {err}") + + async def set_water_setpoint(self, temperature: float, duration: str = "forever") -> bool: + """Set water setpoint temperature.""" + try: + setpoint_url = f"{self.base_url}/index.php?Action=SetParameter" + + # Prepare form data + form_data = { + "Key": "watersetpoint", + "Value": str(temperature), + "manual": duration + } + + _LOGGER.debug(f"Setting watersetpoint to {temperature} with duration {duration}") + + async with aiohttp.ClientSession() as session: + async with session.post( + setpoint_url, + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) as response: + if response.status != 200: + _LOGGER.error(f"Failed to set water setpoint: {response.status}") + return False + _LOGGER.info(f"Successfully set water setpoint to {temperature}") + # Force an immediate data refresh + await self.async_request_refresh() + return True + except Exception as err: + _LOGGER.error(f"Error setting water setpoint: {err}") + return False + + async def set_dhw_setpoint(self, temperature: float, duration: str = "forever") -> bool: + """Set water setpoint temperature.""" + try: + setpoint_url = f"{self.base_url}/index.php?Action=SetParameter" + + # Prepare form data + form_data = { + "Key": "watersetpoint", + "Value": str(temperature), + "manual": duration + } + + _LOGGER.debug(f"Setting watersetpoint to {temperature} with duration {duration}") + + async with aiohttp.ClientSession() as session: + async with session.post( + setpoint_url, + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) as response: + if response.status != 200: + _LOGGER.error(f"Failed to set water setpoint: {response.status}") + return False + _LOGGER.info(f"Successfully set water setpoint to {temperature}") + # Force an immediate data refresh + await self.async_request_refresh() + return True + except Exception as err: + _LOGGER.error(f"Error setting water setpoint: {err}") + return False + +class HeatPumpEntity(CoordinatorEntity): + """Base class for heat pump entities.""" + + def __init__(self, coordinator, sensor_type=None): + """Initialize the entity.""" + super().__init__(coordinator) + self.sensor_type = sensor_type + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, "heat_pump")}, + "name": "Heat Pump", + "manufacturer": "RDZ", + "model": "PDC", + "sw_version": self.coordinator.data.get("software_version", "Unknown"), + } \ No newline at end of file diff --git a/binary_sensor.py b/custom_components/ha_rdz_pdc_config/binary_sensor.py similarity index 84% rename from binary_sensor.py rename to custom_components/ha_rdz_pdc_config/binary_sensor.py index cd7022f..dc181cc 100644 --- a/binary_sensor.py +++ b/custom_components/ha_rdz_pdc_config/binary_sensor.py @@ -1,13 +1,12 @@ """ -Place this code in custom_components/heat_pump_monitor/binary_sensor.py +Place this code in custom_components/ha_rdz_pdc_config/binary_sensor.py """ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorDeviceClass, ) -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, HeatPumpDataCoordinator +from . import DOMAIN, HeatPumpEntity async def async_setup_entry(hass, entry, async_add_entities): """Set up binary sensors.""" @@ -27,13 +26,12 @@ async def async_setup_entry(hass, entry, async_add_entities): ] async_add_entities(entities) -class HeatPumpOperatingBinarySensor(CoordinatorEntity, BinarySensorEntity): +class HeatPumpOperatingBinarySensor(HeatPumpEntity, BinarySensorEntity): """Binary sensor for heat pump operation states.""" def __init__(self, coordinator, sensor_type, name, device_class): """Initialize the binary sensor.""" - super().__init__(coordinator) - self.sensor_type = sensor_type + super().__init__(coordinator, sensor_type) self._attr_name = f"Heat Pump {name}" self._attr_unique_id = f"{DOMAIN}_{sensor_type}" self._attr_device_class = device_class @@ -41,4 +39,4 @@ class HeatPumpOperatingBinarySensor(CoordinatorEntity, BinarySensorEntity): @property def is_on(self): """Return true if the binary sensor is on.""" - return self.coordinator.data.get(self.sensor_type, False) + return self.coordinator.data.get(self.sensor_type, False) \ No newline at end of file diff --git a/custom_components/ha_rdz_pdc_config/climate.py b/custom_components/ha_rdz_pdc_config/climate.py new file mode 100644 index 0000000..ae62256 --- /dev/null +++ b/custom_components/ha_rdz_pdc_config/climate.py @@ -0,0 +1,92 @@ +""" +Place this code in custom_components/ha_rdz_pdc_config/climate.py +""" +import logging +from typing import Any, Dict, List, Optional + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, + HVACAction, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + UnitOfTemperature, +) + +from . import DOMAIN, HeatPumpEntity + +_LOGGER = logging.getLogger(__name__) + +# Define available HVAC modes based on heat pump capabilities +HVAC_MODES = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the heat pump climate.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([HeatPumpClimate(coordinator)]) + +class HeatPumpClimate(HeatPumpEntity, ClimateEntity): + """Heat pump climate entity.""" + + def __init__(self, coordinator): + """Initialize the heat pump climate entity.""" + super().__init__(coordinator) + self._attr_name = "Heat Pump Water Temperature" + self._attr_unique_id = f"{DOMAIN}_climate" + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_hvac_modes = HVAC_MODES + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_min_temp = 35 + self._attr_max_temp = 60 + self._attr_target_temperature_step = 0.5 + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + # Use T1 (inflow temperature) as the current temperature + return self.coordinator.data.get("water_temp_t1") + + @property + def target_temperature(self) -> Optional[float]: + """Return the target temperature.""" + return self.coordinator.data.get("water_setpoint") + + @property + def hvac_mode(self) -> str: + """Return the current HVAC mode.""" + if self.coordinator.data.get("is_standby", False): + return HVACMode.OFF + elif self.coordinator.data.get("is_cooling", False): + return HVACMode.COOL + else: + return HVACMode.HEAT + + @property + def hvac_action(self) -> Optional[str]: + """Return the current HVAC action.""" + if self.coordinator.data.get("is_standby", False): + return HVACAction.OFF + elif self.coordinator.data.get("is_cooling", False) and self.coordinator.data.get("is_working", False): + return HVACAction.COOLING + elif self.coordinator.data.get("is_heating", False) and self.coordinator.data.get("is_working", False): + return HVACAction.HEATING + else: + return HVACAction.IDLE + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + if ATTR_TEMPERATURE not in kwargs: + return + + temperature = kwargs[ATTR_TEMPERATURE] + await self.coordinator.set_water_setpoint(temperature) + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set HVAC mode.""" + # Note: This would require additional endpoints to control the heat pump mode + # For now, we'll just log that this functionality isn't implemented + _LOGGER.warning("Setting HVAC mode is not currently supported by this integration") + # In the future, you could implement this if the heat pump API supports mode changes \ No newline at end of file diff --git a/config_flow.py b/custom_components/ha_rdz_pdc_config/config_flow.py similarity index 100% rename from config_flow.py rename to custom_components/ha_rdz_pdc_config/config_flow.py diff --git a/manifest.json b/custom_components/ha_rdz_pdc_config/manifest.json similarity index 100% rename from manifest.json rename to custom_components/ha_rdz_pdc_config/manifest.json diff --git a/sensor.py b/custom_components/ha_rdz_pdc_config/sensor.py similarity index 78% rename from sensor.py rename to custom_components/ha_rdz_pdc_config/sensor.py index caa614b..66be67e 100644 --- a/sensor.py +++ b/custom_components/ha_rdz_pdc_config/sensor.py @@ -1,5 +1,5 @@ """ -Place this code in custom_components/heat_pump_monitor/sensor.py +Place this code in custom_components/ha_rdz_pdc_config/sensor.py """ from homeassistant.components.sensor import ( SensorEntity, @@ -7,9 +7,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import UnitOfTemperature, UnitOfTime -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, HeatPumpDataCoordinator +from . import DOMAIN, HeatPumpEntity async def async_setup_entry(hass, entry, async_add_entities): """Set up sensors.""" @@ -31,13 +30,12 @@ async def async_setup_entry(hass, entry, async_add_entities): ] async_add_entities(entities) -class HeatPumpTemperatureSensor(CoordinatorEntity, SensorEntity): +class HeatPumpTemperatureSensor(HeatPumpEntity, SensorEntity): """Temperature sensor for heat pump.""" def __init__(self, coordinator, sensor_type, name): """Initialize the sensor.""" - super().__init__(coordinator) - self.sensor_type = sensor_type + super().__init__(coordinator, sensor_type) self._attr_name = f"Heat Pump {name}" self._attr_unique_id = f"{DOMAIN}_{sensor_type}" self._attr_device_class = SensorDeviceClass.TEMPERATURE @@ -49,13 +47,12 @@ class HeatPumpTemperatureSensor(CoordinatorEntity, SensorEntity): """Return the state of the sensor.""" return self.coordinator.data.get(self.sensor_type) -class HeatPumpTimeSensor(CoordinatorEntity, SensorEntity): +class HeatPumpTimeSensor(HeatPumpEntity, SensorEntity): """Time sensor for heat pump operation hours.""" def __init__(self, coordinator, sensor_type, name): """Initialize the sensor.""" - super().__init__(coordinator) - self.sensor_type = sensor_type + super().__init__(coordinator, sensor_type) self._attr_name = f"Heat Pump {name}" self._attr_unique_id = f"{DOMAIN}_{sensor_type}" self._attr_device_class = SensorDeviceClass.DURATION @@ -65,21 +62,19 @@ class HeatPumpTimeSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the state of the sensor.""" - # Convert the value from hours to seconds for Home Assistant - hours = self.coordinator.data.get(self.sensor_type, 0) - return hours + # Return the hours value directly + return self.coordinator.data.get(self.sensor_type, 0) -class HeatPumpVersionSensor(CoordinatorEntity, SensorEntity): +class HeatPumpVersionSensor(HeatPumpEntity, SensorEntity): """Software version sensor for heat pump.""" def __init__(self, coordinator, sensor_type, name): """Initialize the sensor.""" - super().__init__(coordinator) - self.sensor_type = sensor_type + super().__init__(coordinator, sensor_type) self._attr_name = f"Heat Pump {name}" self._attr_unique_id = f"{DOMAIN}_{sensor_type}" @property def native_value(self): """Return the state of the sensor.""" - return self.coordinator.data.get(self.sensor_type) + return self.coordinator.data.get(self.sensor_type) \ No newline at end of file diff --git a/translations/en.json b/custom_components/ha_rdz_pdc_config/translations/en.json similarity index 100% rename from translations/en.json rename to custom_components/ha_rdz_pdc_config/translations/en.json diff --git a/custom_components/ha_rdz_pdc_config/water_heater.py b/custom_components/ha_rdz_pdc_config/water_heater.py new file mode 100644 index 0000000..cd24cb3 --- /dev/null +++ b/custom_components/ha_rdz_pdc_config/water_heater.py @@ -0,0 +1,53 @@ +""" +Place this code in custom_components/ha_rdz_pdc_config/water_heater.py +""" +from homeassistant.components.water_heater import ( + WaterHeaterEntity, + WaterHeaterEntityFeature, + STATE_OFF, + STATE_ON, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from . import DOMAIN, HeatPumpEntity + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up water heater.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([HeatPumpWaterHeater(coordinator)]) + +class HeatPumpWaterHeater(HeatPumpEntity, WaterHeaterEntity): + """Heat pump water heater entity.""" + + def __init__(self, coordinator): + """Initialize the water heater entity.""" + super().__init__(coordinator) + self._attr_name = "Heat Pump DHW" + self._attr_unique_id = f"{DOMAIN}_water_heater" + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_min_temp = 35 + self._attr_max_temp = 60 + self._attr_target_temperature_step = 0.5 + self._attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.coordinator.data.get("dhw_temp_t3") + + @property + def target_temperature(self): + """Return the target temperature.""" + return self.coordinator.data.get("dhw_setpoint") + + @property + def state(self): + """Return the current state.""" + if self.coordinator.data.get("is_dhw", False): + return STATE_ON + return STATE_OFF + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.coordinator.set_dhw_setpoint(temperature) \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..5d68d15 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff format . +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt