Skip to content
Closed
12 changes: 12 additions & 0 deletions custom_components/zont_ha/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json
import logging
from datetime import timedelta
Expand Down Expand Up @@ -145,6 +146,17 @@ async def handle_webhook(
f'Device id: {webhook_id}. '
f'Body: {pretty_json}')
await coordinator.async_request_refresh()

# target_temp обновляется в read-API ZONT с задержкой
# (~10с, eventual consistency) после смены режима, поэтому
# первый опрос ловит старую уставку. Добираем настоявшееся
# состояние несколькими повторными опросами.
async def _resync():
for delay in (6, 8):
await asyncio.sleep(delay)
await coordinator.async_request_refresh()

hass.async_create_task(_resync())
except ValueError:
_LOGGER.warning(f'Wrong webhook request. Webhook id: {webhook_id}. '
f'Body: {body}')
Expand Down
155 changes: 113 additions & 42 deletions custom_components/zont_ha/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@
from . import ZontCoordinator, DOMAIN
from .const import (
TIME_OUT_REQUEST, MAX_TEMP_AIR, MIN_TEMP_AIR, MODELS_THERMOSTAT_ZONT,
ENTRIES, CURRENT_ENTITY_IDS, PLUS, PRO
ENTRIES, CURRENT_ENTITY_IDS, HVAC_OFF_TEMP, DHW_ON_TEMP
)
from .core.enums import TypeOfCircuit
from .core.exceptions import TemperatureOutOfRangeError, SetHvacModeError
from .core.exceptions import TemperatureOutOfRangeError
from .core.models_zont_v1 import DeviceZontOld
from .core.models_zont_v3 import CircuitZONT, DeviceZONT
from .core.models_zont_v3 import CircuitZONT, DeviceZONT, HeatingModeZONT
from .core.zont import Zont

_LOGGER = logging.getLogger(__name__)

_OFF_MODE_KEYWORDS = ('выкл', 'откл', 'off')


async def async_setup_entry(
hass: HomeAssistant,
Expand Down Expand Up @@ -57,7 +59,9 @@ class ZontClimateEntity(CoordinatorEntity, ClimateEntity):
_attr_min_temp = MIN_TEMP_AIR
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE |
ClimateEntityFeature.PRESET_MODE
ClimateEntityFeature.PRESET_MODE |
ClimateEntityFeature.TURN_ON |
ClimateEntityFeature.TURN_OFF
)
_enable_turn_on_off_backwards_compatibility: bool = False

Expand All @@ -75,6 +79,13 @@ def __init__(
self._attr_min_temp, self._attr_max_temp = (
self._zont.get_min_max_values_temp(self._circuit))
self._attr_device_info = coordinator.devices_info(device.id)
self._circuit_id = circuit.id
self._circuit_available = True

@property
def available(self) -> bool:
# Контур ГВС пропадает из API, когда горячая вода выключена в ЛК.
return super().available and self._circuit_available

@property
def preset_modes(self) -> list[str] | None:
Expand All @@ -86,18 +97,27 @@ def preset_modes(self) -> list[str] | None:

@property
def preset_mode(self) -> str | None:
heating_mode_id = self._circuit.current_mode
heating_mode = self._zont.get_heating_mode_by_id(
self._device, heating_mode_id
)
if heating_mode is not None:
return heating_mode.name
return PRESET_NONE
# Выводим из target-состояния контура, а НЕ из current_mode:
# режим на H-1 device-wide и одинаков у всех контуров, из-за чего
# смена режима отопления «дёргала» бы и ГВС. target раздельный.
if self.hvac_mode == HVACMode.OFF:
mode = self._find_circuit_mode(exclude_off=False)
else:
mode = self._find_circuit_mode(exclude_off=True)
return mode.name if mode is not None else PRESET_NONE

@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
if self._circuit.is_off:
"""Return hvac operation ie. heat, cool mode.

По target_temp контура (раздельный для ГВС/Отопления, в отличие от
device-wide current_mode): <= HVAC_OFF_TEMP (5°) -> OFF, иначе HEAT.
Так контуры независимы и hvac совпадает с preset_mode.
"""
target = self._circuit.target_temp
if self._circuit.is_off or (
target is not None and target <= HVAC_OFF_TEMP
):
return HVACMode.OFF
return HVACMode.HEAT

Expand Down Expand Up @@ -151,25 +171,8 @@ async def async_set_preset_mode(self, preset_mode):
heating_mode = self._zont.get_heating_mode_by_name(
self._device, preset_mode
)
model = self._device.device_info.model
if heating_mode is not None:
if self._device.device_info.model in MODELS_THERMOSTAT_ZONT:
await self._zont.set_heating_mode_all_circuits(
device=self._device,
heating_mode=heating_mode
)
elif PLUS in model.lower() or PRO in model.lower():
await self._zont.set_heating_mode(
device=self._device,
circuit=self._circuit,
heating_mode_id=heating_mode.id
)
else:
await self._zont.set_heating_mode_v1(
device=self._device,
circuit=self._circuit,
heating_mode_id=heating_mode.id
)
await self._async_apply_heating_mode(heating_mode)
else:
await self._zont.set_target_temperature(
device=self._device,
Expand All @@ -180,25 +183,93 @@ async def async_set_preset_mode(self, preset_mode):
await asyncio.sleep(TIME_OUT_REQUEST)
await self.coordinator.async_request_refresh()

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode.

OFF -> активировать режим «Выключен» (он сам ставит теплоноситель 5°).
HEAT -> активировать режим «Комфорт» (он сам ставит 40°).
Уставку вручную НЕ трогаем: ручная уставка сбрасывает режим в None
(current_mode -> None) и режим не отображается активным в ЛК.
Активация режима и ставит температуру, и делает режим активным.
"""
if hvac_mode not in (HVACMode.OFF, HVACMode.HEAT):
return

if self._circuit.type == TypeOfCircuit.DHW:
# У ГВС нет своих режимов, а device-wide режим задел бы
# отопление. Управляем уставкой контура (per-circuit).
target = HVAC_OFF_TEMP if hvac_mode == HVACMode.OFF else DHW_ON_TEMP
await self._zont.set_target_temperature(
device=self._device, circuit=self._circuit, target_temp=target
)
else:
mode = self._find_circuit_mode(
exclude_off=(hvac_mode == HVACMode.HEAT)
)
if mode is not None:
await self._async_apply_heating_mode(mode)
else:
_LOGGER.warning(
f'Режим для hvac {hvac_mode} не найден '
f'для контура {self._circuit.name}'
)
await asyncio.sleep(TIME_OUT_REQUEST)
await self.coordinator.async_request_refresh()

@staticmethod
def _mode_is_off(mode: HeatingModeZONT | None) -> bool:
"""True if the heating mode represents the circuit 'off' state."""
if mode is None:
return False
return any(kw in mode.name.lower() for kw in _OFF_MODE_KEYWORDS)

def _find_circuit_mode(self, exclude_off: bool) -> HeatingModeZONT | None:
"""Find first applicable circuit mode matching the off/heat filter."""
for mode in self._device.modes:
if self._circuit.id not in mode.can_be_applied:
continue
if self._mode_is_off(mode) != exclude_off:
return mode
return None

async def _async_apply_heating_mode(self, heating_mode: HeatingModeZONT) -> None:
"""Apply heating mode respecting device API type (widget_type)."""
model = self._device.device_info.model
device_id = self._device.device_info.id
widget_type = self._device.device_info.widget_type
if model in MODELS_THERMOSTAT_ZONT or device_id in MODELS_THERMOSTAT_ZONT:
await self._zont.set_heating_mode_all_circuits(
device=self._device,
heating_mode=heating_mode
)
elif widget_type == "z3k":
await self._zont.set_heating_mode_v1(
device=self._device,
circuit=self._circuit,
heating_mode_id=heating_mode.id
)
else:
await self._zont.set_heating_mode(
device=self._device,
circuit=self._circuit,
heating_mode_id=heating_mode.id
)

def __repr__(self) -> str:
if not self.hass:
return f"<Climate entity {self.name}>"
return f'<Climate entity {self.name}>'
return super().__repr__()

def set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
raise SetHvacModeError(
'Изменение HVAC не поддерживается ZONT. '
'Контур управляется котлом.'
)

@callback
def _handle_coordinator_update(self) -> None:
"""Обработка обновлённых данных от координатора"""
self._device: DeviceZONT = self.coordinator.zont.get_device(
self._device.id
)
self._circuit = self._zont.get_circuit(
self._device, self._circuit.id
)
circuit = self._zont.get_circuit(self._device, self._circuit_id)
# ГВС-контур исчезает из API при выключенной горячей воде ->
# помечаем сущность недоступной, а не падаем на None.
self._circuit_available = circuit is not None
if circuit is not None:
self._circuit = circuit
self.async_write_ha_state()
11 changes: 10 additions & 1 deletion custom_components/zont_ha/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,17 @@
MAX_TEMP_GVS = 75
MIN_TEMP_FLOOR = 15
MAX_TEMP_FLOOR = 45
MIN_TEMP_HEATING = 5
MAX_TEMP_HEATING = 80
MATCHES_GVS = ('гвс', 'горяч', 'вода', 'бкн', 'гидро', 'подача')
MATCHES_FLOOR = ('пол', 'тёплый',)
MATCHES_HEATING = ('отопл', 'теплонос',)

# Порог hvac OFF: режим «Выключен» ставит теплоноситель в 5°,
# поэтому target <= HVAC_OFF_TEMP в климате трактуется как OFF.
HVAC_OFF_TEMP = 5
# Уставка ГВС при включении из HA (у ГВС нет режимов, управляем уставкой).
DHW_ON_TEMP = 50

BUTTON_ZONT = 'button'
SWITCH_ZONT = 'toggle_button'
Expand All @@ -122,7 +131,7 @@
TIME_OUT_UPDATE_DATA = 10
TIME_OUT_REPEAT = 10
TIME_OUT_REQUEST = 2
TIME_UPDATE = 60
TIME_UPDATE = 30

MODELS_THERMOSTAT_ZONT = ('T100', 'T102')
PLUS = '+'
Expand Down
2 changes: 1 addition & 1 deletion custom_components/zont_ha/core/models_zont_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Coordinates(BaseModel):
class AdditionalInfo(BaseModel):
"""Дополнительная информация объекта."""

object_id: str | int
object_id: str | int | None = None


class DeviceEventWebhook(BaseModel):
Expand Down
6 changes: 5 additions & 1 deletion custom_components/zont_ha/core/zont.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
URL_GET_DEVICES, URL_SEND_COMMAND_ZONT_OLD,
MIN_TEMP_AIR, MAX_TEMP_AIR, MIN_TEMP_GVS, MAX_TEMP_GVS, MIN_TEMP_FLOOR,
MAX_TEMP_FLOOR, MATCHES_GVS, MATCHES_FLOOR,
MIN_TEMP_HEATING, MAX_TEMP_HEATING, MATCHES_HEATING,
BINARY_SENSOR_TYPES, URL_GET_DEVICES_OLD, NO_ERROR,
ZONT_API_URL,
)
Expand Down Expand Up @@ -346,6 +347,8 @@ def get_min_max_values_temp(
val_min, val_max = MIN_TEMP_GVS, MAX_TEMP_GVS
elif any([x in circuit_name for x in MATCHES_FLOOR]):
val_min, val_max = MIN_TEMP_FLOOR, MAX_TEMP_FLOOR
elif any([x in circuit_name for x in MATCHES_HEATING]):
val_min, val_max = MIN_TEMP_HEATING, MAX_TEMP_HEATING
return val_min, val_max

def get_status_control(
Expand Down Expand Up @@ -386,7 +389,7 @@ async def set_heating_mode(
method='POST',
path=f'{ZONT_API_URL}devices/{device.id}/modes/'
f'{heating_mode_id}/actions/activate',
json={'circuit_id': circuit.id},
json={'circuit_ids': [circuit.id]},
headers=self.headers
)

Expand Down Expand Up @@ -422,6 +425,7 @@ async def set_heating_mode_all_circuits(
method='POST',
path=f'{ZONT_API_URL}devices/{device.id}/modes/'
f'{heating_mode.id}/actions/activate',
json={},
headers=self.headers
)

Expand Down