diff --git a/custom_components/panasonic_cc/__init__.py b/custom_components/panasonic_cc/__init__.py index 62ef278..fb3c6f7 100644 --- a/custom_components/panasonic_cc/__init__.py +++ b/custom_components/panasonic_cc/__init__.py @@ -1,4 +1,5 @@ """Platform for the Panasonic Comfort Cloud.""" + import logging from typing import Dict @@ -15,6 +16,7 @@ from homeassistant.loader import async_get_integration from aio_panasonic_comfort_cloud import ApiClient from aioaquarea import Client as AquareaApiClient, AquareaEnvironment +from aioaquarea.errors import AuthenticationError from .const import ( CONF_UPDATE_INTERVAL_VERSION, @@ -120,24 +122,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if api.has_unknown_devices or AQUAREA_DEMO: try: - + _LOGGER.info("Starting Aquarea setup...") if not AQUAREA_DEMO: + _LOGGER.info("Creating Aquarea API client with real credentials") aquarea_api_client = AquareaApiClient(client, username, password) await aquarea_api_client.login() else: + _LOGGER.info("Creating Aquarea API client in DEMO mode") aquarea_api_client = AquareaApiClient(client, environment=AquareaEnvironment.DEMO) - aquarea_api_client._access_token = 'dummy' - aquarea_api_client._token_expiration = None - aquarea_devices = await aquarea_api_client.get_devices(include_long_id=True) + aquarea_api_client._api_client.access_token = 'dummy' + aquarea_api_client._api_client.token_expiration = None + _LOGGER.info("Fetching Aquarea devices...") + aquarea_devices = await aquarea_api_client.get_devices() + _LOGGER.info(f"Found {len(aquarea_devices)} Aquarea device(s)") for aquarea_device in aquarea_devices: try: + _LOGGER.info(f"Setting up Aquarea device: {aquarea_device.name}") aquarea_device_coordinator = AquareaDeviceCoordinator(hass, conf, aquarea_api_client, aquarea_device) await aquarea_device_coordinator.async_config_entry_first_refresh() aquarea_coordinators.append(aquarea_device_coordinator) + _LOGGER.info(f"Successfully setup Aquarea device: {aquarea_device.name}") except Exception as e: - _LOGGER.warning(f"Failed to setup Aquarea device: {aquarea_device.name} ({e})", exc_info=e) + _LOGGER.error(f"Failed to setup Aquarea device: {aquarea_device.name} ({e})", exc_info=e) + except AuthenticationError as e: + _LOGGER.error(f"Aquarea authentication failed (2FA may be required): {e}", exc_info=e) + _LOGGER.error("The Aquarea API currently does not support 2FA/MFA verification") + _LOGGER.error("Please check your Panasonic account settings and disable 2FA if needed") except Exception as e: - _LOGGER.warning(f"Failed to setup Aquarea: {e}", exc_info=e) + _LOGGER.error(f"Failed to setup Aquarea: {e}", exc_info=e) hass.data[DOMAIN][DATA_COORDINATORS] = data_coordinators diff --git a/custom_components/panasonic_cc/button.py b/custom_components/panasonic_cc/button.py index d71e1b2..11e68f3 100644 --- a/custom_components/panasonic_cc/button.py +++ b/custom_components/panasonic_cc/button.py @@ -18,12 +18,18 @@ class PanasonicButtonEntityDescription(ButtonEntityDescription): func: Callable[[PanasonicDeviceCoordinator], Awaitable[Any]] | None = None +async def _update_app_version(coordinator: PanasonicDeviceCoordinator) -> None: + """Update app version.""" + app_version = getattr(coordinator.api_client, '_app_version', None) + if app_version and hasattr(app_version, 'refresh'): + await app_version.refresh() + APP_VERSION_DESCRIPTION = PanasonicButtonEntityDescription( key="update_app_version", name="Fetch latest app version", icon="mdi:refresh", entity_category=EntityCategory.DIAGNOSTIC, - func = lambda coordinator: coordinator.api_client.update_app_version() + func = _update_app_version ) UPDATE_DATA_DESCRIPTION = ButtonEntityDescription( diff --git a/custom_components/panasonic_cc/const.py b/custom_components/panasonic_cc/const.py index 77e95c8..68e127e 100644 --- a/custom_components/panasonic_cc/const.py +++ b/custom_components/panasonic_cc/const.py @@ -88,7 +88,7 @@ PANASONIC_DEVICES = "panasonic_devices" DATA_COORDINATORS = "data_coordinators" ENERGY_COORDINATORS = "energy_coordinators" -AQUAREA_COORDINATORS = "aquarea_coorinators" +AQUAREA_COORDINATORS = "aquarea_coordinators" COMPONENT_TYPES = [ Platform.CLIMATE, diff --git a/custom_components/panasonic_cc/coordinator.py b/custom_components/panasonic_cc/coordinator.py index 98d1e9b..3afdcc6 100644 --- a/custom_components/panasonic_cc/coordinator.py +++ b/custom_components/panasonic_cc/coordinator.py @@ -51,12 +51,15 @@ def device_id(self) -> str: @property def device_info(self)->DeviceInfo: + # Access app_version via private attribute + app_version = getattr(self._api_client, '_app_version', None) + version = app_version.version if app_version and hasattr(app_version, 'version') else '1.0.0' return DeviceInfo( identifiers={(DOMAIN, self._panasonic_device_info.id )}, manufacturer=MANUFACTURER, model=self._panasonic_device_info.model, name=self._panasonic_device_info.name, - sw_version=self._api_client.app_version + sw_version=version ) def get_change_request_builder(self): @@ -126,12 +129,15 @@ def energy(self) -> PanasonicDeviceEnergy | None: @property def device_info(self)->DeviceInfo: + # Access app_version via private attribute + app_version = getattr(self._api_client, '_app_version', None) + version = app_version.version if app_version and hasattr(app_version, 'version') else '1.0.0' return DeviceInfo( identifiers={(DOMAIN, self._panasonic_device_info.id )}, manufacturer=MANUFACTURER, model=self._panasonic_device_info.model, name=self._panasonic_device_info.name, - sw_version=self._api_client.app_version + sw_version=version ) async def _fetch_device_data(self)->int: @@ -165,7 +171,13 @@ def __init__(self, hass: HomeAssistant, config: dict, api_client: AquareaApiClie self._aquarea_device_info = device_info self._device:AquareaDevice | None = None self._update_id = 0 - self._is_demo = api_client._environment == AquareaEnvironment.DEMO + # Access environment via private attribute (_environment) from client + self._is_demo = self._get_environment() == AquareaEnvironment.DEMO + + def _get_environment(self) -> AquareaEnvironment: + """Get environment from client.""" + # Access private attribute _environment to avoid modifying vendor library + return getattr(self._api_client, '_environment', AquareaEnvironment.PRODUCTION) @property def device(self) -> AquareaDevice: @@ -188,8 +200,8 @@ def device_info(self)->DeviceInfo: identifiers={(DOMAIN, self.device_id)}, manufacturer=self.device.manufacturer, model="", - name=self.device.name, - sw_version=self.device.version, + name=self.device.device_name, + sw_version=self.device.firmware_version, ) async def _fetch_device_data(self)->int: diff --git a/custom_components/panasonic_cc/manifest.json b/custom_components/panasonic_cc/manifest.json index 8e0388e..6494ea8 100644 --- a/custom_components/panasonic_cc/manifest.json +++ b/custom_components/panasonic_cc/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/sockless-coding/panasonic_cc/issues", - "requirements": ["aiohttp","aio-panasonic-comfort-cloud==2025.5.1","aioaquarea==0.7.2"], + "requirements": ["aiohttp","aio-panasonic-comfort-cloud==2025.5.1","aioaquarea==1.0.0"], "quality_scale": "silver" } diff --git a/custom_components/panasonic_cc/number.py b/custom_components/panasonic_cc/number.py index 81accfa..3512204 100644 --- a/custom_components/panasonic_cc/number.py +++ b/custom_components/panasonic_cc/number.py @@ -2,7 +2,9 @@ from typing import Callable from dataclasses import dataclass -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTemperature + +_LOGGER = logging.getLogger(__name__) from homeassistant.core import HomeAssistant from homeassistant.components.number import ( NumberDeviceClass, @@ -12,11 +14,13 @@ ) from aio_panasonic_comfort_cloud import PanasonicDevice, PanasonicDeviceZone, ChangeRequestBuilder +from aioaquarea import Device as AquareaDevice +from aioaquarea import ExtendedOperationMode from . import DOMAIN -from .const import DATA_COORDINATORS -from .coordinator import PanasonicDeviceCoordinator -from .base import PanasonicDataEntity +from .const import DATA_COORDINATORS, AQUAREA_COORDINATORS +from .coordinator import PanasonicDeviceCoordinator, AquareaDeviceCoordinator +from .base import PanasonicDataEntity, AquareaDataEntity @dataclass(frozen=True, kw_only=True) class PanasonicNumberEntityDescription(NumberEntityDescription): @@ -24,6 +28,11 @@ class PanasonicNumberEntityDescription(NumberEntityDescription): get_value: Callable[[PanasonicDevice], int] set_value: Callable[[ChangeRequestBuilder, int], ChangeRequestBuilder] +@dataclass(frozen=True, kw_only=True) +class AquareaNumberEntityDescription(NumberEntityDescription): + """Describes Aquarea Number entity.""" + zone_id: int + def create_zone_damper_description(zone: PanasonicDeviceZone): return PanasonicNumberEntityDescription( key = f"zone-{zone.id}-damper", @@ -49,9 +58,51 @@ async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): data_coordinator, create_zone_damper_description(zone))) + # Aquarea Number entities for temperature targets + aquarea_coordinators: list[AquareaDeviceCoordinator] = hass.data[DOMAIN][AQUAREA_COORDINATORS] + for coordinator in aquarea_coordinators: + for zone_id in coordinator.device.zones: + zone = coordinator.device.zones.get(zone_id) + # Add heat target temperature + devices.append(AquareaNumberEntity( + coordinator, + AquareaNumberEntityDescription( + zone_id=zone_id, + key=f"zone-{zone_id}-heat-target", + translation_key=f"zone-{zone_id}-heat-target", + name=f"{zone.name} Heat Target", + icon="mdi:thermometer", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_max_value=zone.heat_max if zone.heat_max is not None else 30, + native_min_value=zone.heat_min if zone.heat_min is not None else 10, + native_step=1, + mode=NumberMode.BOX, + ) + )) + # Add cool target temperature if supported + if zone.cool_max and zone.cool_min: + devices.append(AquareaNumberEntity( + coordinator, + AquareaNumberEntityDescription( + zone_id=zone_id, + key=f"zone-{zone_id}-cool-target", + translation_key=f"zone-{zone_id}-cool-target", + name=f"{zone.name} Cool Target", + icon="mdi:thermometer", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_max_value=zone.cool_max if zone.cool_max is not None else 30, + native_min_value=zone.cool_min if zone.cool_min is not None else 16, + native_step=1, + mode=NumberMode.BOX, + ) + )) + async_add_entities(devices) class PanasonicNumberEntity(PanasonicDataEntity, NumberEntity): + """Representation of a Panasonic Number.""" entity_description: PanasonicNumberEntityDescription @@ -70,4 +121,56 @@ async def async_set_native_value(self, value: float) -> None: self.async_write_ha_state() def _async_update_attrs(self) -> None: - self._attr_native_value = self.entity_description.get_value(self.coordinator.device) \ No newline at end of file + self._attr_native_value = self.entity_description.get_value(self.coordinator.device) + + +class AquareaNumberEntity(AquareaDataEntity, NumberEntity): + """Aquarea Number entity for setting target temperatures.""" + + entity_description: AquareaNumberEntityDescription + + def __init__(self, coordinator: AquareaDeviceCoordinator, description: AquareaNumberEntityDescription): + """Initialize the number entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + async def async_set_native_value(self, value: float) -> None: + """Set new target temperature value.""" + zone_id = self.entity_description.zone_id + temperature = int(value) + + # Determine if we're setting heat or cool based on key + if "heat-target" in self.entity_description.key: + # Temporarily switch mode to HEAT if needed + original_mode = self.coordinator.device.mode + if original_mode not in (ExtendedOperationMode.HEAT, ExtendedOperationMode.AUTO_HEAT): + _LOGGER.debug(f"Switching to HEAT mode to set heat target for zone {zone_id}") + await self.coordinator.device.set_temperature(temperature, zone_id) + elif "cool-target" in self.entity_description.key: + # Temporarily switch mode to COOL if needed + original_mode = self.coordinator.device.mode + if original_mode not in (ExtendedOperationMode.COOL, ExtendedOperationMode.AUTO_COOL): + _LOGGER.debug(f"Switching to COOL mode to set cool target for zone {zone_id}") + await self.coordinator.device.set_temperature(temperature, zone_id) + + self._attr_native_value = temperature + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _async_update_attrs(self) -> None: + """Update the current value.""" + zone = self.coordinator.device.zones.get(self.entity_description.zone_id) + if zone: + # When heatSet/coolSet is not available from API, display current temperature as reference + if "heat-target" in self.entity_description.key: + # Try to get target temperature first, fallback to current temperature + temp = zone.heat_target_temperature + if temp is None: + # API doesn't return setpoint, use current temperature as reference + temp = zone.temperature + self._attr_native_value = temp + elif "cool-target" in self.entity_description.key: + temp = zone.cool_target_temperature + if temp is None: + temp = zone.temperature + self._attr_native_value = temp diff --git a/custom_components/panasonic_cc/select.py b/custom_components/panasonic_cc/select.py index 3fda240..4e4a6e7 100644 --- a/custom_components/panasonic_cc/select.py +++ b/custom_components/panasonic_cc/select.py @@ -1,14 +1,18 @@ -from typing import Callable +from typing import Callable, Any from dataclasses import dataclass +import logging from homeassistant.core import HomeAssistant from homeassistant.components.select import SelectEntity, SelectEntityDescription -from .const import DOMAIN, DATA_COORDINATORS, SELECT_HORIZONTAL_SWING, SELECT_VERTICAL_SWING +from .const import DOMAIN, DATA_COORDINATORS, AQUAREA_COORDINATORS, SELECT_HORIZONTAL_SWING, SELECT_VERTICAL_SWING from aio_panasonic_comfort_cloud import PanasonicDevice, ChangeRequestBuilder, constants -from .coordinator import PanasonicDeviceCoordinator -from .base import PanasonicDataEntity +from .coordinator import PanasonicDeviceCoordinator, AquareaDeviceCoordinator +from .base import PanasonicDataEntity, AquareaDataEntity +from aioaquarea import Device as AquareaDevice, SpecialStatus, PowerfulTime + +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class PanasonicSelectEntityDescription(SelectEntityDescription): @@ -18,6 +22,13 @@ class PanasonicSelectEntityDescription(SelectEntityDescription): is_available: Callable[[PanasonicDevice], bool] get_options: Callable[[PanasonicDevice], list[str]] = None +@dataclass(frozen=True, kw_only=True) +class AquareaSelectEntityDescription(SelectEntityDescription): + """Description of an Aquarea select entity.""" + get_current_option: Callable[[AquareaDevice], str] + set_option: Callable[[AquareaDevice, str], Any] # Returns awaitable + is_available: Callable[[AquareaDevice], bool] + HORIZONTAL_SWING_DESCRIPTION = PanasonicSelectEntityDescription( key=SELECT_HORIZONTAL_SWING, @@ -40,13 +51,43 @@ class PanasonicSelectEntityDescription(SelectEntityDescription): is_available = lambda device : True ) +# Aquarea Select entities +AQUAREA_SPECIAL_STATUS_DESCRIPTION = AquareaSelectEntityDescription( + key="special_status", + translation_key="special_status", + name="Special Status", + icon="mdi:thermostat", + options=["NORMAL", "ECO", "COMFORT"], + get_current_option=lambda device: device.special_status.name if device.special_status else "NORMAL", + set_option=lambda device, value: device.set_special_status(SpecialStatus[value] if value != "NORMAL" else None), + is_available=lambda device: device.support_special_status +) + +AQUAREA_POWERFUL_TIME_DESCRIPTION = AquareaSelectEntityDescription( + key="powerful_time", + translation_key="powerful_time", + name="Powerful Mode", + icon="mdi:timer", + options=["OFF", "30MIN", "60MIN", "90MIN"], + get_current_option=lambda device: device.powerful_time.name, + set_option=lambda device, value: device.set_powerful_time(PowerfulTime[value]), + is_available=lambda device: True +) + async def async_setup_entry(hass: HomeAssistant, entry, async_add_entities): entities = [] data_coordinators: list[PanasonicDeviceCoordinator] = hass.data[DOMAIN][DATA_COORDINATORS] + aquarea_coordinators: list[AquareaDeviceCoordinator] = hass.data[DOMAIN][AQUAREA_COORDINATORS] + for coordinator in data_coordinators: entities.append(PanasonicSelectEntity(coordinator, HORIZONTAL_SWING_DESCRIPTION)) entities.append(PanasonicSelectEntity(coordinator, VERTICAL_SWING_DESCRIPTION)) + + # Add Aquarea entities + for coordinator in aquarea_coordinators: + entities.append(AquareaSelectEntity(coordinator, AQUAREA_SPECIAL_STATUS_DESCRIPTION)) + entities.append(AquareaSelectEntity(coordinator, AQUAREA_POWERFUL_TIME_DESCRIPTION)) async_add_entities(entities) @@ -77,3 +118,29 @@ async def async_select_option(self, option: str) -> None: def _async_update_attrs(self) -> None: self.current_option = self.entity_description.get_current_option(self.coordinator.device) + +class AquareaSelectEntity(AquareaDataEntity, SelectEntity): + """Representation of an Aquarea select entity.""" + + entity_description: AquareaSelectEntityDescription + + def __init__(self, coordinator: AquareaDeviceCoordinator, description: AquareaSelectEntityDescription): + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.entity_description.is_available(self.coordinator.device) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_option(self.coordinator.device, option) + self._attr_current_option = option + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _async_update_attrs(self) -> None: + """Update the current option.""" + self._attr_current_option = self.entity_description.get_current_option(self.coordinator.device) + diff --git a/custom_components/panasonic_cc/sensor.py b/custom_components/panasonic_cc/sensor.py index 8786eee..f012894 100644 --- a/custom_components/panasonic_cc/sensor.py +++ b/custom_components/panasonic_cc/sensor.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import logging -from homeassistant.const import UnitOfTemperature, EntityCategory +from homeassistant.const import UnitOfTemperature, EntityCategory, PERCENTAGE from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, @@ -175,6 +175,47 @@ class AquareaSensorEntityDescription(SensorEntityDescription): is_available=lambda device: device.temperature_outdoor is not None, ) +AQUAREA_PUMP_DUTY_DESCRIPTION = AquareaSensorEntityDescription( + key="pump_duty", + translation_key="pump_duty", + name="Pump Duty", + icon="mdi:speedometer", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + get_state=lambda device: int(device.pump_duty), + is_available=lambda device: True +) + +AQUAREA_DIRECTION_DESCRIPTION = AquareaSensorEntityDescription( + key="direction", + translation_key="direction", + name="Direction", + icon="mdi:swap-horizontal", + get_state=lambda device: device.current_direction.name if device.current_direction else "UNKNOWN", + is_available=lambda device: True +) + +AQUAREA_DEVICE_MODE_STATUS_DESCRIPTION = AquareaSensorEntityDescription( + key="device_mode_status", + translation_key="device_mode_status", + name="Device Mode Status", + icon="mdi:information", + entity_category=EntityCategory.DIAGNOSTIC, + get_state=lambda device: device.device_mode_status.name if device.device_mode_status else "UNKNOWN", + is_available=lambda device: True +) + +AQUAREA_FAULT_STATUS_DESCRIPTION = AquareaSensorEntityDescription( + key="fault_status", + translation_key="fault_status", + name="Fault Status", + icon="mdi:alert", + entity_category=EntityCategory.DIAGNOSTIC, + get_state=lambda device: device.current_error.error_message if device.is_on_error else "OK", + is_available=lambda device: True +) + def create_zone_temperature_description(zone: PanasonicDeviceZone): return PanasonicSensorEntityDescription( key = f"zone-{zone.id}-temperature", @@ -217,6 +258,10 @@ async def async_setup_entry(hass, entry, async_add_entities): for coordinator in aquarea_coordinators: entities.append(AquareaSensorEntity(coordinator, AQUAREA_OUTSIDE_TEMPERATURE_DESCRIPTION)) + entities.append(AquareaSensorEntity(coordinator, AQUAREA_PUMP_DUTY_DESCRIPTION)) + entities.append(AquareaSensorEntity(coordinator, AQUAREA_DIRECTION_DESCRIPTION)) + entities.append(AquareaSensorEntity(coordinator, AQUAREA_DEVICE_MODE_STATUS_DESCRIPTION)) + entities.append(AquareaSensorEntity(coordinator, AQUAREA_FAULT_STATUS_DESCRIPTION)) async_add_entities(entities) diff --git a/requirements.txt b/requirements.txt index 5c0c3a5..49f4f09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ aiohttp aio-panasonic-comfort-cloud==2025.5.1 -aioaquarea==0.7.2 \ No newline at end of file +aioaquarea==1.0.0