Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ariston/ariston_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@
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"""
Expand All @@ -85,14 +93,14 @@
devices = self._get(f"{self.__api_url}{ARISTON_REMOTE}/{ARISTON_PLANTS}")
if devices is not None:
return list(devices)
return list()

Check warning on line 96 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVOOFmXP_NXX6x5&open=AZ9WbSVOOFmXP_NXX6x5&pullRequest=184

def get_detailed_velis_devices(self) -> list[Any]:
"""Get detailed cloud devices"""
devices = self._get(f"{self.__api_url}{ARISTON_VELIS}/{ARISTON_PLANTS}")
if devices is not None:
return list(devices)
return list()

Check warning on line 103 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x6&open=AZ9WbSVPOFmXP_NXX6x6&pullRequest=184

def get_devices(self) -> list[Any]:
"""Get cloud devices"""
Expand All @@ -101,7 +109,7 @@
)
if devices is not None:
return list(devices)
return list()

Check warning on line 112 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x7&open=AZ9WbSVPOFmXP_NXX6x7&pullRequest=184

def get_features_for_device(self, gw_id: str) -> dict[str, Any]:
"""Get features for the device"""
Expand All @@ -110,7 +118,7 @@
)
if features is not None:
return features
return dict()

Check warning on line 121 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x8&open=AZ9WbSVPOFmXP_NXX6x8&pullRequest=184

def get_energy_account(self, gw_id: str) -> dict[str, Any]:
"""Get energy account for the device"""
Expand All @@ -119,7 +127,7 @@
)
if energy_account is not None:
return energy_account
return dict()

Check warning on line 130 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x9&open=AZ9WbSVPOFmXP_NXX6x9&pullRequest=184

def get_consumptions_sequences(self, gw_id: str, usages: str) -> list[Any]:
"""Get consumption sequences for the device"""
Expand All @@ -128,7 +136,7 @@
)
if consumptions_sequences is not None:
return list(consumptions_sequences)
return list()

Check warning on line 139 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x-&open=AZ9WbSVPOFmXP_NXX6x-&pullRequest=184

def get_consumptions_settings(self, gw_id: str) -> dict[str, Any]:
"""Get consumption settings"""
Expand All @@ -138,7 +146,7 @@
)
if consumptions_settings is not None:
return consumptions_settings
return dict()

Check warning on line 149 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6x_&open=AZ9WbSVPOFmXP_NXX6x_&pullRequest=184

def set_consumptions_settings(
self,
Expand Down Expand Up @@ -174,7 +182,7 @@
)
if properties is not None:
return properties
return dict()

Check warning on line 185 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yA&open=AZ9WbSVPOFmXP_NXX6yA&pullRequest=184

def get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]:
"""Get BSB plant data."""
Expand All @@ -183,14 +191,14 @@
)
if data is not None:
return data
return dict()

Check warning on line 194 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yB&open=AZ9WbSVPOFmXP_NXX6yB&pullRequest=184

def get_velis_plant_data(self, plant_data: PlantData, gw_id: str) -> dict[str, Any]:
"""Get Velis properties"""
data = self._get(f"{self.__api_url}{ARISTON_VELIS}/{plant_data.value}/{gw_id}")
if data is not None:
return data
return dict()

Check warning on line 201 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yC&open=AZ9WbSVPOFmXP_NXX6yC&pullRequest=184

def get_velis_plant_settings(
self, plant_data: PlantData, gw_id: str
Expand All @@ -201,7 +209,7 @@
)
if settings is not None:
return settings
return dict()

Check warning on line 212 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yD&open=AZ9WbSVPOFmXP_NXX6yD&pullRequest=184

def get_menu_items(self, gw_id: str) -> list[dict[str, Any]]:
"""Get menu items"""
Expand All @@ -210,7 +218,7 @@
)
if items is not None:
return items
return list()

Check warning on line 221 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yE&open=AZ9WbSVPOFmXP_NXX6yE&pullRequest=184

def set_property(
self,
Expand Down Expand Up @@ -437,7 +445,7 @@
)
if thermostat_time_progs is not None:
return thermostat_time_progs
return dict()

Check warning on line 448 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yF&open=AZ9WbSVPOFmXP_NXX6yF&pullRequest=184

def set_holiday(
self,
Expand Down Expand Up @@ -544,7 +552,7 @@
)
if detailed_devices is not None:
return list(detailed_devices)
return list()

Check warning on line 555 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yG&open=AZ9WbSVPOFmXP_NXX6yG&pullRequest=184

async def async_get_detailed_velis_devices(self) -> list[Any]:
"""Async get detailed cloud devices"""
Expand All @@ -553,7 +561,7 @@
)
if detailed_velis_devices is not None:
return list(detailed_velis_devices)
return list()

Check warning on line 564 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yH&open=AZ9WbSVPOFmXP_NXX6yH&pullRequest=184

async def async_get_devices(self) -> list[Any]:
"""Async get cloud devices"""
Expand All @@ -562,7 +570,7 @@
)
if devices is not None:
return list(devices)
return list()

Check warning on line 573 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yI&open=AZ9WbSVPOFmXP_NXX6yI&pullRequest=184

async def async_get_features_for_device(
self, gw_id: str
Expand All @@ -579,7 +587,7 @@
)
if energy_account is not None:
return energy_account
return dict()

Check warning on line 590 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yJ&open=AZ9WbSVPOFmXP_NXX6yJ&pullRequest=184

async def async_get_consumptions_sequences(
self, gw_id: str, usages: str
Expand All @@ -590,7 +598,7 @@
)
if consumptions_sequences is not None:
return list(consumptions_sequences)
return list()

Check warning on line 601 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yK&open=AZ9WbSVPOFmXP_NXX6yK&pullRequest=184

async def async_get_consumptions_settings(self, gw_id: str) -> dict[str, Any]:
"""Async get consumption settings"""
Expand All @@ -600,7 +608,7 @@
)
if consumptions_settings is not None:
return consumptions_settings
return dict()

Check warning on line 611 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yL&open=AZ9WbSVPOFmXP_NXX6yL&pullRequest=184

async def async_set_consumptions_settings(
self,
Expand Down Expand Up @@ -628,7 +636,7 @@
)
if properties is not None:
return properties
return dict()

Check warning on line 639 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yM&open=AZ9WbSVPOFmXP_NXX6yM&pullRequest=184

async def async_get_bsb_plant_data(self, gw_id: str) -> dict[str, Any]:
"""Get BSB plant data."""
Expand All @@ -637,7 +645,7 @@
)
if data is not None:
return data
return dict()

Check warning on line 648 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yN&open=AZ9WbSVPOFmXP_NXX6yN&pullRequest=184

async def async_get_velis_plant_data(
self, plant_data: PlantData, gw_id: str
Expand All @@ -648,7 +656,7 @@
)
if med_plant_data is not None:
return med_plant_data
return dict()

Check warning on line 659 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yO&open=AZ9WbSVPOFmXP_NXX6yO&pullRequest=184

async def async_get_velis_plant_settings(
self, plant_data: PlantData, gw_id: str
Expand All @@ -659,7 +667,7 @@
)
if med_plant_settings is not None:
return med_plant_settings
return dict()

Check warning on line 670 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yP&open=AZ9WbSVPOFmXP_NXX6yP&pullRequest=184

async def async_get_menu_items(self, gw_id: str) -> list[dict[str, Any]]:
"""Async get menu items"""
Expand All @@ -668,7 +676,7 @@
)
if items is not None:
return items
return list()

Check warning on line 679 in ariston/ariston_api.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSVPOFmXP_NXX6yQ&open=AZ9WbSVPOFmXP_NXX6yQ&pullRequest=184

async def async_set_property(
self,
Expand Down
66 changes: 66 additions & 0 deletions ariston/default_viewmodel_template.json
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 77 additions & 1 deletion ariston/galevo_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -43,9 +45,10 @@
super().__init__(api, attributes)
self.umsys = "si" if is_metric else "us"
self.language_tag = language_tag
self.consumptions_settings: dict[str, Any] = dict()

Check warning on line 48 in ariston/galevo_device.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSWEOFmXP_NXX6yR&open=AZ9WbSWEOFmXP_NXX6yR&pullRequest=184
self.energy_account: dict[str, Any] = dict()

Check warning on line 49 in ariston/galevo_device.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSWEOFmXP_NXX6yS&open=AZ9WbSWEOFmXP_NXX6yS&pullRequest=184
self.menu_items: list[dict[str, Any]] = list()

Check warning on line 50 in ariston/galevo_device.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSWEOFmXP_NXX6yT&open=AZ9WbSWEOFmXP_NXX6yT&pullRequest=184
self._legacy_client: AristonR2LegacyClient | None = None

@property
def consumption_type(self) -> str:
Expand All @@ -57,6 +60,13 @@
"""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:
Expand Down Expand Up @@ -884,9 +894,75 @@
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(

Check warning on line 902 in ariston/galevo_device.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused local variable "previous_value".

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSWEOFmXP_NXX6yU&open=AZ9WbSWEOFmXP_NXX6yU&pullRequest=184
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):

Check warning on line 962 in ariston/galevo_device.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSWEOFmXP_NXX6yV&open=AZ9WbSWEOFmXP_NXX6yV&pullRequest=184
# """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)
Expand Down
203 changes: 203 additions & 0 deletions ariston/legacy_r2_client.py
Original file line number Diff line number Diff line change
@@ -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:

Check warning on line 90 in ariston/legacy_r2_client.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use asynchronous features in this function or remove the `async` keyword.

See more on https://sonarcloud.io/project/issues?id=fustom_python-ariston-api&issues=AZ9WbSSxOFmXP_NXX6x4&open=AZ9WbSSxOFmXP_NXX6x4&pullRequest=184
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()