From 549bb256c762f4f215de6f1d6d93397a69e2bf41 Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Mon, 6 Apr 2026 15:53:35 +0300 Subject: [PATCH] Initial commit --- .gitignore | 78 ++++++++++++++++++++++ __init__.py | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ binarySensor.py | 36 ++++++++++ climate.py | 77 +++++++++++++++++++++ config_flow.py | 35 ++++++++++ manifest.json | 10 +++ sensor.py | 54 +++++++++++++++ 7 files changed, 463 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 binarySensor.py create mode 100644 climate.py create mode 100644 config_flow.py create mode 100644 manifest.json create mode 100644 sensor.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ddbdd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# ── OS ───────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Secrets / environment ─────────────────────────────────────────────────── +.env +.env.local +.env.*.local +*.pem +*.key +*.p12 +*.pfx + +# ── Logs ─────────────────────────────────────────────────────────────────── +*.log +logs/ + +# ── Compiled / bytecode ───────────────────────────────────────────────────── +*.class +*.pyc +*.pyo +*.pyd +__pycache__/ +*.o +*.a +*.so +*.dylib +*.dll +*.exe +*.lib + +# ── Archives ─────────────────────────────────────────────────────────────── +*.zip +*.tar +*.tar.gz +*.tgz +*.tar.bz2 +*.rar +*.7z + +# ── Java / JVM ────────────────────────────────────────────────────────────── +*.jar +*.war +*.ear +target/ +.gradle/ +build/ +.m2/ + +# ── JavaScript / Node ─────────────────────────────────────────────────────── +node_modules/ +dist/ +.next/ +.nuxt/ +.svelte-kit/ +.output/ + +# ── Python ────────────────────────────────────────────────────────────────── +.venv/ +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +.tox/ +.pytest_cache/ +htmlcov/ +.coverage + +# ── IDE ───────────────────────────────────────────────────────────────────── +.idea/ +.vscode/ +*.iml +.eclipse/ + +# ── Project-specific exclusions ──────────────────────────────────────────── +homeAssistant.RDZ.PDC.iml diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e297e55 --- /dev/null +++ b/__init__.py @@ -0,0 +1,173 @@ +""" +Custom integration for heat pump monitoring and control via web interface. +Place this code in custom_components/heat_pump_monitor/__init__.py +""" +import asyncio +import logging +import aiohttp +from datetime import timedelta +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_URL, + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "heat_pump_monitor" + +# Default update interval (3 seconds as per your web interface) +DEFAULT_SCAN_INTERVAL = 3 + +# Add climate to the platforms +PLATFORMS = ["sensor", "binary_sensor", "climate"] + +# 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_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up heat pump monitor from a config entry.""" + url = entry.data[CONF_URL] + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + coordinator = HeatPumpDataCoordinator( + hass, + _LOGGER, + url=url, + update_interval=timedelta(seconds=scan_interval), + ) + + await coordinator.async_config_entry_first_refresh() + + 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.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + url: str, + update_interval: timedelta, + ) -> None: + """Initialize.""" + super().__init__( + hass, + logger, + name=DOMAIN, + update_interval=update_interval, + ) + self.url = url + self.base_url = url.rsplit('/', 1)[0] # Remove the last part of the URL to get the base URL + + async def _async_update_data(self): + """Fetch data from heat pump.""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(self.url) as response: + if response.status != 200: + raise UpdateFailed(f"Error fetching data: {response.status}") + data = await response.json() + # Add water setpoint to the data + water_setpoint = data.get("watersetpoint", 40.0) + return { + "is_operating": data.get("is_operating", False), + "is_heating": data.get("is_heating", False), + "aux_heat": data.get("aux_heat", False), + "water_mode": data.get("water_mode", "unknown"), + "watersetpoint": water_setpoint, # Add water setpoint + "current_temperature": data.get("current_temperature", 0.0), # Add current temperature if available + } + except aiohttp.ClientError as err: + 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 + +class HeatPumpEntity(CoordinatorEntity): + """Base class for heat pump entities.""" + + def __init__(self, coordinator, key=None): + """Initialize the entity.""" + super().__init__(coordinator) + self.key = key + + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, "heat_pump")}, + "name": "Heat Pump", + "manufacturer": "Custom Heat Pump", + "model": "Web Interface", + "sw_version": "1.0", + } \ No newline at end of file diff --git a/binarySensor.py b/binarySensor.py new file mode 100644 index 0000000..34815cc --- /dev/null +++ b/binarySensor.py @@ -0,0 +1,36 @@ +""" +Place this code in custom_components/heat_pump_monitor/binary_sensor.py +""" +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass, +) + +from . import DOMAIN +from .__init__ import HeatPumpEntity + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up binary sensors.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + HeatPumpOperatingBinarySensor(coordinator, "is_operating", "Operating", BinarySensorDeviceClass.POWER), + HeatPumpOperatingBinarySensor(coordinator, "is_heating", "Heating", BinarySensorDeviceClass.HEAT), + HeatPumpOperatingBinarySensor(coordinator, "aux_heat", "Auxiliary Heat", BinarySensorDeviceClass.HEAT), + ] + async_add_entities(entities) + +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, sensor_type) + self._attr_name = f"Heat Pump {name}" + self._attr_unique_id = f"{DOMAIN}_{sensor_type}" + self._attr_device_class = device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.coordinator.data.get(self.key, False) \ No newline at end of file diff --git a/climate.py b/climate.py new file mode 100644 index 0000000..103188c --- /dev/null +++ b/climate.py @@ -0,0 +1,77 @@ +""" +Place this code in custom_components/heat_pump_monitor/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 +from .__init__ import HeatPumpEntity + +_LOGGER = logging.getLogger(__name__) + +HVAC_MODES = [HVACMode.HEAT] + +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_hvac_mode = HVACMode.HEAT + 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.""" + return self.coordinator.data.get("current_temperature") + + @property + def target_temperature(self) -> Optional[float]: + """Return the target temperature.""" + return self.coordinator.data.get("watersetpoint") + + @property + def hvac_action(self) -> Optional[str]: + """Return the current HVAC action.""" + if self.coordinator.data.get("is_heating"): + return HVACAction.HEATING + if self.coordinator.data.get("is_operating"): + return HVACAction.IDLE + return HVACAction.OFF + + 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.""" + # This integration only supports HEAT mode, so we don't need to implement any changes + pass \ No newline at end of file diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..2580a76 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,35 @@ +""" +Place this code in custom_components/heat_pump_monitor/config_flow.py +""" +from homeassistant import config_entries +from homeassistant.const import CONF_URL, CONF_SCAN_INTERVAL +import voluptuous as vol + +class HeatPumpMonitorConfigFlow(config_entries.ConfigFlow, domain="heat_pump_monitor"): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + return self.async_create_entry( + title="Heat Pump Monitor", + data={ + CONF_URL: user_input[CONF_URL], + CONF_SCAN_INTERVAL: user_input.get(CONF_SCAN_INTERVAL, 3), + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_SCAN_INTERVAL, default=3): int, + } + ), + errors=errors, + ) \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..54dbf03 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "heat_pump_monitor", + "name": "Heat Pump Monitor", + "documentation": "https://github.com/your_username/heat_pump_monitor", + "dependencies": [], + "codeowners": ["@your_username"], + "requirements": [], + "iot_class": "local_polling", + "version": "1.0.0" +} \ No newline at end of file diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..a7e9d21 --- /dev/null +++ b/sensor.py @@ -0,0 +1,54 @@ +""" +Place this code in custom_components/heat_pump_monitor/sensor.py +""" +from homeassistant.components.sensor import ( + SensorEntity, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature + +from . import DOMAIN +from .__init__ import HeatPumpEntity + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up sensors.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + HeatPumpModeSensor(coordinator, "water_mode"), + HeatPumpTemperatureSensor(coordinator, "current_temperature", "Current Temperature"), + ] + async_add_entities(entities) + +class HeatPumpModeSensor(HeatPumpEntity, SensorEntity): + """Sensor for heat pump water mode.""" + + def __init__(self, coordinator, key): + """Initialize the sensor.""" + super().__init__(coordinator, key) + self._attr_name = "Heat Pump Water Mode" + self._attr_unique_id = f"{DOMAIN}_water_mode" + self._attr_device_class = SensorDeviceClass.ENUM + + @property + def native_value(self): + """Return the state of the sensor.""" + return self.coordinator.data.get(self.key) + +class HeatPumpTemperatureSensor(HeatPumpEntity, SensorEntity): + """Sensor for heat pump temperature values.""" + + def __init__(self, coordinator, key, name): + """Initialize the sensor.""" + super().__init__(coordinator, key) + self._attr_name = f"Heat Pump {name}" + self._attr_unique_id = f"{DOMAIN}_{key}" + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + self._attr_state_class = SensorStateClass.MEASUREMENT + + @property + def native_value(self): + """Return the state of the sensor.""" + return self.coordinator.data.get(self.key) \ No newline at end of file