From 5fdd8df58f15b6306187691306d21efebf53e3c7 Mon Sep 17 00:00:00 2001 From: Alberto Rosso Date: Sun, 12 Jul 2026 14:43:29 +0200 Subject: [PATCH] Fix plantmode write no-op on Elco Aerotop device classified as GALEVO instead of bsb --- ariston/ariston_api.py | 8 + ariston/default_viewmodel_template.json | 66 ++++++++ ariston/galevo_device.py | 78 ++++++++- ariston/legacy_r2_client.py | 203 ++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 1 deletion(-) create mode 100755 ariston/default_viewmodel_template.json create mode 100644 ariston/legacy_r2_client.py diff --git a/ariston/ariston_api.py b/ariston/ariston_api.py index 465e078..a1b3924 100644 --- a/ariston/ariston_api.py +++ b/ariston/ariston_api.py @@ -60,6 +60,14 @@ def __init__( self.__api_url = api_url self.__token = "" self.__user_agent = user_agent + + @property + def username(self) -> str: + return self.__username + + @property + def password(self) -> str: + return self.__password def connect(self) -> bool: """Login to ariston cloud and get token""" diff --git a/ariston/default_viewmodel_template.json b/ariston/default_viewmodel_template.json new file mode 100755 index 0000000..db9ac1c --- /dev/null +++ b/ariston/default_viewmodel_template.json @@ -0,0 +1,66 @@ +{ + "plantMode": 5, + "plantModeAsText": "Off", + "zoneMode": 2, + "zoneNumber": 1, + "zoneNumberAsText": "CASA", + "holidayExpiresOn": null, + "holidayExpiresOnAsText": "", + "isZoneFireplace": false, + "fireplaceExpiresOn": null, + "isAccessRequestAvailable": false, + "isAccessRequestGranted": false, + "isProposedDeroga": false, + "isZoneDeroga": false, + "isPilotOn": false, + "derogaTemp": 0, + "derogaExpiresOn": null, + "desiredTemp": { + "gatewayId": "PLACEHOLDER_GATEWAY_ID", + "zone": 1, + "id": "ZoneDesiredTemp", + "kind": 1, + "min": -3276.8, + "max": 3276.7, + "step": 0.5, + "value": 24, + "decimals": 1, + "options": [], + "optTexts": [], + "readOnly": true, + "error": false, + "invalid": false, + "expiresOn": null, + "unit": "°C" + }, + "antifreezeTemp": 5, + "roomTemp": 0, + "comfortTemp": 37, + "reducedTemp": 0, + "bottomSheetMessage": "", + "bottomSheetSecondary": "", + "bottomSheetMessageType": 0, + "isBottomSheetActionVisible": false, + "chFlowSetpointTemp": "18.0°C", + "heatingCircuitPressure": "Non disponibile", + "outsideTemp": "25 °C", + "weatherIcon": "gfm-icon--weather-nuvoloso", + "weatherCreditsUrl": "https://openweathermap.org", + "isWeatherVisible": true, + "virtOperativeMode": 1, + "isOptimumStartRunning": false, + "dhwCardVisible": false, + "advSettingsCardVisible": true, + "bottomSheetPrimaryActionKind": 0, + "isBottomSheetVisible": false, + "plantModeEval": 5, + "isWinter": false, + "isSummer": false, + "isHeating": false, + "isCooling": false, + "isOff": true, + "isHoliday": false, + "isZoneManual": false, + "isZoneOff": false, + "isZoneProgram": false +} \ No newline at end of file diff --git a/ariston/galevo_device.py b/ariston/galevo_device.py index 59c489b..ca2f418 100644 --- a/ariston/galevo_device.py +++ b/ariston/galevo_device.py @@ -6,6 +6,8 @@ import logging from datetime import date from typing import Any, Optional +import asyncio +from .legacy_r2_client import AristonR2LegacyClient, PLANT_MODE_VALUES from .ariston_api import AristonAPI from .const import ( @@ -46,6 +48,7 @@ def __init__( self.consumptions_settings: dict[str, Any] = dict() self.energy_account: dict[str, Any] = dict() self.menu_items: list[dict[str, Any]] = list() + self._legacy_client: AristonR2LegacyClient | None = None @property def consumption_type(self) -> str: @@ -57,6 +60,13 @@ def plant_mode_supported(self) -> bool: """Returns whether plant mode is supported""" return True + def _get_legacy_client(self) -> AristonR2LegacyClient: + if self._legacy_client is None: + self._legacy_client = AristonR2LegacyClient( + self.api.username, self.api.password + ) + return self._legacy_client + def _update_state(self) -> None: """Set custom features""" if self.custom_features.get(CustomDeviceFeatures.HAS_OUTSIDE_TEMP) is None: @@ -884,9 +894,75 @@ def set_plant_mode(self, plant_mode: PlantMode): self.set_item_by_id(DeviceProperties.PLANT_MODE, plant_mode.value) async def async_set_plant_mode(self, plant_mode: PlantMode): - """Async set plant mode""" + """Async set plant mode, with fallback to the legacy /R2/ write + path if the modern REST v2 write is confirmed to have no real + effect on the device (observed on some Elco/BSB heat pumps that + are classified as GALEVO by the cloud, but whose physical unit + does not react to the modern write endpoint).""" + previous_value = self._get_item_by_id( + DeviceProperties.PLANT_MODE, PropertyType.VALUE, 0 + ) + await self.async_set_item_by_id(DeviceProperties.PLANT_MODE, plant_mode.value) + # Give the cloud a moment to propagate, then verify. + # Confirmed method name: async_update_state() calls + # self.api.async_get_properties(...) - the same REST v2 read + # channel, which we've confirmed reflects real device state + # correctly (only the *write* path is broken for these devices). + # 2s is a starting point - tune based on real-world testing; + # too short risks a false-positive fallback trigger if the cloud + # hasn't propagated yet. + await asyncio.sleep(2) + await self.async_update_state() + confirmed_value = self._get_item_by_id( + DeviceProperties.PLANT_MODE, PropertyType.VALUE, 0 + ) + + if confirmed_value == plant_mode.value: + _LOGGER.debug( + "PlantMode set via REST v2 confirmed (value=%s)", plant_mode.value + ) + return + + _LOGGER.warning( + "PlantMode write via REST v2 had no effect (still %s, expected " + "%s) - falling back to legacy /R2/ endpoint", + confirmed_value, + plant_mode.value, + ) + + plant_mode_text = self._get_item_by_id( + DeviceProperties.PLANT_MODE, PropertyType.OPT_TEXTS, 0 + )[ + self._get_item_by_id( + DeviceProperties.PLANT_MODE, PropertyType.OPTIONS, 0 + ).index(plant_mode.value) + ] + + legacy_client = self._get_legacy_client() + + # First-time use: the legacy client needs a seeded viewModel + # template, since the site provides no clean read endpoint for it. + # In a first implementation this would need to be captured once + # (manually, via HAR, as done during development) and shipped as + # a static fallback template bundled with this module - see + # legacy_r2_client.py docstring. A future improvement could try + # to extract it from the initial /R2/Plant/Index page HTML + # instead, removing the need for a static template entirely. + if legacy_client._viewmodel_cache is None: + raise NotImplementedError( + "Legacy fallback requires a seeded viewModel template - " + "see legacy_r2_client.py docstring for how to obtain one." + ) + + await legacy_client.async_set_plant_mode(self.gw, plant_mode_text) + self._set_item_by_id(DeviceProperties.PLANT_MODE, plant_mode.value, 0) + + #async def async_set_plant_mode(self, plant_mode: PlantMode): + # """Async set plant mode""" + # await self.async_set_item_by_id(DeviceProperties.PLANT_MODE, plant_mode.value) + def set_zone_mode(self, zone_mode: ZoneMode, zone: int): """Set zone mode""" self.set_item_by_id(ThermostatProperties.ZONE_MODE, zone_mode.value, zone) diff --git a/ariston/legacy_r2_client.py b/ariston/legacy_r2_client.py new file mode 100644 index 0000000..b966288 --- /dev/null +++ b/ariston/legacy_r2_client.py @@ -0,0 +1,203 @@ +""" +Legacy R2 (remocon-net) client. + +Some GALEVO-classified devices (observed on Elco heat pumps with a BSB +controller) accept writes on the modern REST v2 endpoint +(`/api/v2/remote/dataItems/{gw}/set`) with a `{"success": true}` response, +but the command has NO effect on the physical device. + +The official web client (remocon-net.remotethermo.com / ariston-net...) +for these same devices uses a legacy ASP.NET MVC-style endpoint instead: + + POST /R2/PlantHome/GetData/{gatewayId}?umsys={umsys} (read) + POST /R2/PlantHome/SetData/{gatewayId}?umsys={umsys} (write) + +This module is a minimal, isolated client for that legacy path, used only +as a fallback when the modern REST v2 write is confirmed to have no effect +(see `GalevoDevice.async_set_plant_mode` for the verification/fallback +logic). It intentionally does NOT share the aiohttp session or auth state +of the main `AristonAPI` client, since the two use different auth +mechanisms (session cookies here vs. bearer token there). + +NOTE: `viewModel` returned by the site is NOT available via a clean read +endpoint - it's built client-side in JS from data embedded in the initial +page render. This client works around that by keeping a cached "known +good" viewModel (seeded from a real capture) and patching only the +plant-mode-related fields before writing. This is pragmatic but not fully +general - if Elco/Ariston changes the viewModel schema, or if other +fields turn out to matter for other kinds of writes, this will need +revisiting. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Optional + +import aiohttp + +_LOGGER = logging.getLogger(__name__) + +# Bundled default viewModel template, captured from a real, confirmed- +# working "set to OFF" action on a specific Elco Aerotop / BSB gateway. +# IMPORTANT LIMITATION: this is device/account-specific pragmatism, not a +# general solution. It is used as a best-effort default so the fallback +# works out of the box for the device it was captured from, but other +# devices (different firmware/model) may have a different viewModel shape +# and this template may not apply cleanly. A more general fix would parse +# the live viewModel out of the initial /R2/Plant/Index page HTML instead +# of relying on a static bundled capture - left as a future improvement. +_DEFAULT_TEMPLATE_PATH = os.path.join( + os.path.dirname(__file__), "default_viewmodel_template.json" +) + + +def _load_default_viewmodel() -> Optional[dict[str, Any]]: + try: + with open(_DEFAULT_TEMPLATE_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + +_BASE_URL = "https://www.remocon-net.remotethermo.com" + +# Confirmed via real captures. Note the sparse enum (value 3 does not +# exist) - do NOT derive this from list position/index. +PLANT_MODE_VALUES: dict[str, int] = { + "Estate": 0, # Summer (DHW only) + "Inverno": 1, # Winter (CH + DHW) + "Solo riscaldamento": 2, # Heating only + "Solo raffreddamento": 4, # Cooling only + "OFF": 5, # Off +} + + +class AristonR2LegacyClient: + """Minimal client for the legacy /R2/ write path, used as a fallback + when the REST v2 write silently no-ops on certain devices.""" + + def __init__(self, username: str, password: str) -> None: + self._username = username + self._password = password + self._session: Optional[aiohttp.ClientSession] = None + self._logged_in = False + # Cached full viewModel from a real capture - see module docstring + # and _load_default_viewmodel() for the important caveats. + self._viewmodel_cache: Optional[dict[str, Any]] = _load_default_viewmodel() + + async def _ensure_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Accept": "application/json, text/javascript, */*; q=0.01", + "X-Requested-With": "XMLHttpRequest", + "ajax-request": "json", + } + ) + return self._session + + async def _login(self) -> None: + session = await self._ensure_session() + url = f"{_BASE_URL}/R2/Account/Login?returnUrl=%2FR2%2FHome" + payload = { + "email": self._username, + "password": self._password, + "rememberMe": True, + "language": "Italian", + } + async with session.post(url, json=payload) as resp: + resp.raise_for_status() + body = await resp.json() + if not body.get("ok", True): + raise RuntimeError(f"Legacy R2 login failed: {body}") + self._logged_in = True + _LOGGER.debug("Legacy R2 client: login successful") + + async def _get_data(self, gateway_id: str, umsys: str) -> dict[str, Any]: + session = await self._ensure_session() + url = f"{_BASE_URL}/R2/PlantHome/GetData/{gateway_id}?umsys={umsys}" + payload = { + "filter": {"notEssentials": False, "plant": True, "zone": True, "dhw": True}, + "useCache": False, + "zone": 1, + } + async with session.post(url, json=payload) as resp: + resp.raise_for_status() + body = await resp.json() + if not body.get("ok"): + raise RuntimeError(f"Legacy R2 GetData failed: {body}") + return body["data"] + + async def async_set_plant_mode( + self, + gateway_id: str, + plant_mode_text: str, + umsys: str = "si", + ) -> dict[str, Any]: + """Set plant mode via the legacy /R2/ endpoint. + + `plant_mode_text` must be one of PLANT_MODE_VALUES keys, using the + *localized* text as returned by the device's own PlantMode + optTexts (observed in English on some accounts, Italian on + others - callers should pass the exact text seen in the device's + own `optTexts`, not a hardcoded assumption). + """ + if not self._logged_in: + await self._login() + + if self._viewmodel_cache is None: + _LOGGER.warning( + "Legacy R2 client has no cached viewModel yet; a real " + "capture must seed it before this fallback can work. " + "See AristonR2LegacyClient.seed_viewmodel()." + ) + raise RuntimeError("No viewModel template available for legacy write") + + data = await self._get_data(gateway_id, umsys) + items = data["items"] + features = data["features"] + + value = PLANT_MODE_VALUES.get(plant_mode_text) + if value is None: + raise ValueError(f"Unknown plant mode '{plant_mode_text}'") + + view_model = dict(self._viewmodel_cache) # shallow copy is enough here + view_model["plantMode"] = value + view_model["plantModeAsText"] = plant_mode_text + view_model["plantModeEval"] = value + view_model["isOff"] = plant_mode_text == "OFF" + view_model["isSummer"] = plant_mode_text == "Estate" + view_model["isWinter"] = plant_mode_text == "Inverno" + view_model["isHeating"] = plant_mode_text in ("Inverno", "Solo riscaldamento") + view_model["isCooling"] = plant_mode_text == "Solo raffreddamento" + if isinstance(view_model.get("desiredTemp"), dict): + view_model["desiredTemp"] = dict(view_model["desiredTemp"]) + view_model["desiredTemp"]["gatewayId"] = gateway_id + + session = await self._ensure_session() + url = f"{_BASE_URL}/R2/PlantHome/SetData/{gateway_id}?umsys={umsys}" + payload = {"features": features, "prevItems": items, "viewModel": view_model} + async with session.post(url, json=payload) as resp: + resp.raise_for_status() + body = await resp.json() + if not body.get("ok"): + raise RuntimeError(f"Legacy R2 SetData failed: {body}") + _LOGGER.debug( + "Legacy R2 SetData succeeded, changedItems=%s", + body.get("data", {}).get("changedItems"), + ) + return body + + def seed_viewmodel(self, viewmodel: dict[str, Any]) -> None: + """Provide a known-good viewModel captured from a real browser + session (see project docs / HAR capture instructions). Required + before async_set_plant_mode can be used, since the site does not + expose a clean read endpoint for the viewModel itself.""" + self._viewmodel_cache = dict(viewmodel) + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close()