From fc5dee179fa260a3f7e5f975196ed765237b0648 Mon Sep 17 00:00:00 2001 From: Arnaldo Garat Date: Tue, 14 Jul 2026 21:58:29 -0400 Subject: [PATCH] Add RusClimate/Syncleo convector heater support (protocol 3) Extends the integration to support RusClimate/Syncleo convector heaters (devtype 46, e.g. Kaltemp Apolo Inverter / Sygonix NCA-1G-INV4.0) over the same local encrypted UDP protocol used by the kettles. All heater behaviour is gated on POLARIS_HEATER_TYPE so kettle support is unaffected. - Protocol 3: kettle.py accepts protocol 2 and 3; protocol.py from_encrypted falls back to UnknownMessage on a per-type parse error (crypto is identical to v2). New outgoing messages for target temperature, mode/preset and heating intensity. - Climate entity (gated): HEAT/OFF, hvac_action, current temp (type 20), target temp 5-35 step 1 (type 2); presets Comfort/Eco/Anti-frost via the mode command (type 1); heating intensity Auto/1..10 as fan_mode (type 15), a fixed level puts the device in manual mode. - Current Power sensor (0..10, type 66 channel 0) and open-window detection switch (type 38) for heaters. - Entity cleanup for heaters: no kettle water_heater / night light; skip the kettle-only Device Hardware sensor; power on/off is exposed only through the climate entity (no redundant standalone switch). - English/Russian translations and icons for the new entities; manifest documentation/issue_tracker URLs and zeroconf discovery for _syncleo._udp. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + custom_components/syncleo_kettle/__init__.py | 4 +- custom_components/syncleo_kettle/climate.py | 157 +++++++++++++----- custom_components/syncleo_kettle/const.py | 32 ++++ .../syncleo_kettle/coordinator.py | 53 +++++- custom_components/syncleo_kettle/icons.json | 14 ++ custom_components/syncleo_kettle/kettle.py | 24 ++- .../syncleo_kettle/manifest.json | 5 +- custom_components/syncleo_kettle/protocol.py | 52 +++++- custom_components/syncleo_kettle/sensor.py | 69 ++++++-- custom_components/syncleo_kettle/switch.py | 77 +++++++-- .../syncleo_kettle/translations/en.json | 20 ++- .../syncleo_kettle/translations/ru.json | 18 +- 13 files changed, 444 insertions(+), 84 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f242c63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +push_err.txt diff --git a/custom_components/syncleo_kettle/__init__.py b/custom_components/syncleo_kettle/__init__.py index d006b4d..3d90b1d 100644 --- a/custom_components/syncleo_kettle/__init__.py +++ b/custom_components/syncleo_kettle/__init__.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -PLATFORMS: list[Platform] = [Platform.WATER_HEATER, Platform.SWITCH, Platform.LIGHT, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.WATER_HEATER, Platform.CLIMATE, Platform.SWITCH, Platform.LIGHT, Platform.SENSOR] async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Syncleo Kettle component.""" @@ -90,4 +90,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator: PolarisDataUpdateCoordinator = hass.data[entry.domain].pop(entry.entry_id) await coordinator.shutdown() - return unload_ok \ No newline at end of file + return unload_ok \ No newline at end of file diff --git a/custom_components/syncleo_kettle/climate.py b/custom_components/syncleo_kettle/climate.py index 9fbe154..a7b366c 100644 --- a/custom_components/syncleo_kettle/climate.py +++ b/custom_components/syncleo_kettle/climate.py @@ -1,4 +1,4 @@ -"""Climate platform for Syncleo Kettle.""" +"""Climate platform for Syncleo heater-class devices (RusClimate convectors).""" from __future__ import annotations import logging @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature @@ -14,48 +15,69 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .coordinator import KettleDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import PolarisDataUpdateCoordinator +from .const import ( + DOMAIN, + POLARIS_HEATER_TYPE, + HEATER_MIN_TEMP, + HEATER_MAX_TEMP, + HEATER_TEMP_STEP, + HEATER_PRESET_COMFORT, + HEATER_PRESET_TO_MODE, + HEATER_MODE_TO_PRESET, + HEATER_FAN_AUTO, + HEATER_FAN_MODES, + HEATER_MAX_INTENSITY, +) from .protocol import PowerType _LOGGER = logging.getLogger(__name__) -SUPPORTED_TEMPERATURES = list(range(40, 101, 5)) # 40°C to 100°C in 5°C steps async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigType, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Syncleo Kettle climate platform from config entry.""" - coordinator: KettleDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - - async_add_entities([SyncleoKettleClimate(coordinator, config_entry.entry_id)]) - -class SyncleoKettleClimate(ClimateEntity): - """Representation of a Syncleo Kettle as a climate device.""" - + """Set up the climate platform. Only for heater-class devices.""" + coordinator: PolarisDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + if coordinator.device_info is None: + _LOGGER.error("Device info not available, cannot create climate entity") + return + + if coordinator.device_info["model_id"] in POLARIS_HEATER_TYPE: + async_add_entities([SyncleoHeaterClimate(coordinator, config_entry.entry_id)]) + + +class SyncleoHeaterClimate(ClimateEntity): + """Representation of a Syncleo/RusClimate convector heater.""" + _attr_has_entity_name = True _attr_name = None - - def __init__(self, coordinator: KettleDataUpdateCoordinator, entry_id: str) -> None: - """Initialize the climate device.""" + _attr_translation_key = "heater" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_preset_modes = list(HEATER_PRESET_TO_MODE.keys()) + _attr_fan_modes = HEATER_FAN_MODES + _attr_min_temp = HEATER_MIN_TEMP + _attr_max_temp = HEATER_MAX_TEMP + _attr_target_temperature_step = HEATER_TEMP_STEP + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__(self, coordinator: PolarisDataUpdateCoordinator, entry_id: str) -> None: + """Initialize the heater climate device.""" self.coordinator = coordinator self._entry_id = entry_id self._attr_unique_id = f"{coordinator._mac}_climate" self._attr_device_info = coordinator.device_info - - # Static attributes - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | - ClimateEntityFeature.TURN_ON | - ClimateEntityFeature.TURN_OFF - ) - self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] - self._attr_min_temp = 40 - self._attr_max_temp = 100 - self._attr_target_temperature_step = 5 @property def available(self) -> bool: @@ -75,21 +97,36 @@ def target_temperature(self) -> float | None: @property def hvac_mode(self) -> HVACMode: """Return current operation mode.""" - power_type = self.coordinator.data.get("power_type") - if power_type == PowerType.OFF: + if self.coordinator.data.get("power_type", PowerType.OFF) == PowerType.OFF: return HVACMode.OFF - else: - return HVACMode.HEAT + return HVACMode.HEAT @property - def hvac_action(self) -> str | None: + def hvac_action(self) -> HVACAction: """Return current HVAC action.""" + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF if self.coordinator.data.get("is_heating", False): - return "heating" - elif self.hvac_mode == HVACMode.HEAT: - return "idle" - else: - return "off" + return HVACAction.HEATING + return HVACAction.IDLE + + @property + def preset_mode(self) -> str | None: + """Return the current preset (comfort/eco/away). + + None when off or when running a manual intensity level (the device then + reports a non-preset "manual" mode). + """ + power_type = self.coordinator.data.get("power_type", PowerType.OFF) + return HEATER_MODE_TO_PRESET.get(power_type.value) + + @property + def fan_mode(self) -> str | None: + """Return the intensity as a fan mode: 'auto' or '1'..'10'.""" + intensity = self.coordinator.data.get("intensity", 0) + if not intensity: + return HEATER_FAN_AUTO + return str(intensity) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -99,10 +136,46 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" if hvac_mode == HVACMode.OFF: - await self.coordinator.async_set_power(PowerType.OFF) - else: # HVACMode.HEAT - # Use CUSTOM mode to allow custom temperature - await self.coordinator.async_set_power(PowerType.CUSTOM) + await self.coordinator.async_set_power_type(PowerType.OFF) + else: # HVACMode.HEAT -> resume in Comfort unless already on + if self.coordinator.data.get("power_type", PowerType.OFF) == PowerType.OFF: + await self.coordinator.async_set_power_type( + PowerType(HEATER_PRESET_TO_MODE[HEATER_PRESET_COMFORT]) + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set a preset (also powers the heater on; resets intensity to Auto).""" + mode_value = HEATER_PRESET_TO_MODE.get(preset_mode) + if mode_value is None: + _LOGGER.warning("Unknown heater preset: %s", preset_mode) + return + await self.coordinator.async_set_power_type(PowerType(mode_value)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the intensity/power level. 'auto' or '1'..'10'. + + A fixed level makes the device switch to its manual mode. + """ + if fan_mode == HEATER_FAN_AUTO: + intensity = 0 + else: + try: + intensity = int(fan_mode) + except ValueError: + _LOGGER.warning("Unknown heater fan mode: %s", fan_mode) + return + intensity = max(1, min(HEATER_MAX_INTENSITY, intensity)) + await self.coordinator.async_set_intensity(intensity) + + async def async_turn_on(self) -> None: + """Turn the heater on (Comfort).""" + await self.coordinator.async_set_power_type( + PowerType(HEATER_PRESET_TO_MODE[HEATER_PRESET_COMFORT]) + ) + + async def async_turn_off(self) -> None: + """Turn the heater off.""" + await self.coordinator.async_set_power_type(PowerType.OFF) async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -113,4 +186,4 @@ async def async_added_to_hass(self) -> None: @property def should_poll(self) -> bool: """No need to poll, coordinator notifies of updates.""" - return False \ No newline at end of file + return False diff --git a/custom_components/syncleo_kettle/const.py b/custom_components/syncleo_kettle/const.py index 213c0de..768d85e 100644 --- a/custom_components/syncleo_kettle/const.py +++ b/custom_components/syncleo_kettle/const.py @@ -291,6 +291,38 @@ POLARIS_KETTLE_WITH_BACKLIGHT_TYPE = ["36","37","51","52","53","54","60","61","62","63","67","82","83","84","85","86","97","98","105","106","117","139","164","175","176","177","188","189","194","196","208","209","223","244","245","253","254","255","260","262","263","271","275","294","308"] POLARIS_KETTLE_WITH_TEA_TIME_MODE_TYPE = ["2","8","51","53","56","58","60","62","85","98","139","165","185","188","205","223","262","263","275","294"] POLARIS_KETTLE_WITH_KEEP_WITH_WARM_MODE_TYPE = ["205","262","294"] +POLARIS_HEATER_TYPE = ["46","65","16","49","64"] + +# --- Heater (convector) climate configuration --- +# Semantics cross-referenced from samoswall/polaris-mqtt (CLIMATES_HEATER) and +# validated against a RusClimate/Syncleo PCH-0320WIFI (devtype 46) on 2026-07-08. +HEATER_MIN_TEMP = 5 +HEATER_MAX_TEMP = 35 +HEATER_TEMP_STEP = 1 + +# The heater reuses the kettle "mode" command (protocol type 1) to carry both power +# and preset in a single value: 0=off, 1=comfort(on), 2=eco, 3=anti-frost. +# These map onto the existing PowerType enum values (OFF/ON/BOILKEEP/WARMUP). +HEATER_PRESET_COMFORT = "comfort" +HEATER_PRESET_ECO = "eco" +HEATER_PRESET_AWAY = "away" # "Anti frost" in the ClimatOn app + +# preset name -> mode value (PowerType numeric value) +HEATER_PRESET_TO_MODE = { + HEATER_PRESET_COMFORT: 1, + HEATER_PRESET_ECO: 2, + HEATER_PRESET_AWAY: 3, +} +# mode value -> preset name (only the "on" modes; 0 = off) +HEATER_MODE_TO_PRESET = {v: k for k, v in HEATER_PRESET_TO_MODE.items()} + +# Heater intensity / power level, exposed as the climate fan_mode. +# type 15: 0 = Auto (tied to the active preset), 1..10 = fixed power level. +# Selecting a fixed level makes the device switch to its manual mode (mode 4); +# selecting a preset resets the intensity back to Auto. +HEATER_FAN_AUTO = "auto" +HEATER_FAN_MODES = [HEATER_FAN_AUTO] + [str(n) for n in range(1, 11)] +HEATER_MAX_INTENSITY = 10 POLARIS_HUMIDDIFIER_TYPE = ["4","15","17","18","25","44","70","71","72","73","74","75","87","99","137","147","153","155","157","158"] POLARIS_HUMIDDIFIER_WITH_IONISER_TYPE = ["4","15","17","18","44","70","72","73","74","137","147","153","155","157","158"] POLARIS_HUMIDDIFIER_WITH_WARM_STREAM_TYPE = ["4","15","17","18","44","70","72","74","147","157","158"] diff --git a/custom_components/syncleo_kettle/coordinator.py b/custom_components/syncleo_kettle/coordinator.py index bc668bc..aea6da5 100644 --- a/custom_components/syncleo_kettle/coordinator.py +++ b/custom_components/syncleo_kettle/coordinator.py @@ -28,9 +28,15 @@ ColorNightMessage, DeviceHardwareMessage, ErrorMessage, - WeightMessage + WeightMessage, + UnknownMessage, ) +# Protocol type carrying the heater intensity/power level (0=Auto, 1..10). +HEATER_INTENSITY_TYPE = 15 +# Protocol type carrying the heater open-window detection toggle (0=off, 1=on). +HEATER_WINDOW_TYPE = 38 + _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) @@ -47,7 +53,7 @@ def __init__(self, hass: HomeAssistant, mac: str, device_token: str) -> None: super().__init__( hass, _LOGGER, - name=f"Polaris Kettle {mac}", + name=f"Syncleo {mac}", update_interval=timedelta(seconds=30), ) @@ -59,6 +65,9 @@ def __init__(self, hass: HomeAssistant, mac: str, device_token: str) -> None: "child_lock": False, "volume": False, "backlight": False, + "intensity": 0, # 0 = Auto, 1..10 = fixed power level (heater) + "current_power": None, # instantaneous power output 0..10 (heater) + "window_detection": False, # open-window detection enabled (heater) "night": False, "color_night": {"r": 0, "g": 0, "b": 0}, "error": False, @@ -236,11 +245,16 @@ def _async_handle_incoming_message(self, message) -> None: elif isinstance(message, ColorNightMessage): self.data["color_night"] = { "r": message.r, - "g": message.g, + "g": message.g, "b": message.b, "w": message.w, "data_length": message.data_length } + # Heater collision: type-66 channel 0 (parsed as ColorNight with w==0) + # carries the current power output (0..10) in its first payload byte. + # Only the heater current_power sensor reads this; harmless for kettles. + if message.w == 0: + self.data["current_power"] = message.r elif isinstance(message, WeightMessage): self.data["weight"] = message.weight _LOGGER.debug("---WEIGHT--- %s grams", message.weight) @@ -251,6 +265,14 @@ def _async_handle_incoming_message(self, message) -> None: elif isinstance(message, ErrorMessage): self.data["error"] = message.value + + elif isinstance(message, UnknownMessage) and message.type == HEATER_INTENSITY_TYPE: + # Heater intensity/power level (type 15): 0=Auto, 1..10. + self.data["intensity"] = message.data[0] if message.data else 0 + + elif isinstance(message, UnknownMessage) and message.type == HEATER_WINDOW_TYPE: + # Heater open-window detection toggle (type 38): 0=off, 1=on. + self.data["window_detection"] = bool(message.data[0]) if message.data else False # Schedule update for entities self.async_set_updated_data(self.data) @@ -549,7 +571,28 @@ def stop(): self.kettle.stop_all() await self._hass.async_add_executor_job(stop) - + + + async def async_set_power_type(self, power_type: PowerType) -> None: + """Set the raw mode/power value (used by the heater for presets).""" + def set_power_type(): + self.kettle.set_power(power_type, lambda x: _LOGGER.debug(f"Power type set callback: {x}")) + + await self._hass.async_add_executor_job(set_power_type) + + async def async_set_intensity(self, intensity: int) -> None: + """Set heater intensity/power level (0=Auto, 1..10).""" + def set_intensity(): + self.kettle.set_intensity(intensity, lambda x: _LOGGER.debug(f"Intensity set callback: {x}")) + + await self._hass.async_add_executor_job(set_intensity) + + async def async_set_window_detection(self, enabled: bool) -> None: + """Enable/disable heater open-window detection.""" + def set_window_detection(): + self.kettle.set_window_detection(enabled, lambda x: _LOGGER.debug(f"Window detection set callback: {x}")) + + await self._hass.async_add_executor_job(set_window_detection) async def async_set_child_lock(self, enabled: bool) -> None: """Set child lock state.""" @@ -588,4 +631,4 @@ def set_color_night(): self.kettle.set_color_night(r, g, b, w, data_length, lambda x: _LOGGER.debug(f"Color night set callback: {x}")) - await self._hass.async_add_executor_job(set_color_night) \ No newline at end of file + await self._hass.async_add_executor_job(set_color_night) \ No newline at end of file diff --git a/custom_components/syncleo_kettle/icons.json b/custom_components/syncleo_kettle/icons.json index b9f6a54..c5d1a80 100644 --- a/custom_components/syncleo_kettle/icons.json +++ b/custom_components/syncleo_kettle/icons.json @@ -22,7 +22,21 @@ } } }, + "climate": { + "heater": { + "default": "mdi:radiator" + } + }, "switch": { + "power": { + "default": "mdi:power" + }, + "window_detection": { + "default": "mdi:window-open-variant", + "state": { + "off": "mdi:window-closed-variant" + } + }, "volume": { "default": "mdi:volume-high", "state": { diff --git a/custom_components/syncleo_kettle/kettle.py b/custom_components/syncleo_kettle/kettle.py index eddaecd..9790f51 100644 --- a/custom_components/syncleo_kettle/kettle.py +++ b/custom_components/syncleo_kettle/kettle.py @@ -18,6 +18,8 @@ NightMessage, ColorNightMessage, TargetTemperatureMessage, + HeatIntensityMessage, + WindowDetectionMessage, PowerType, ConnectionStatus, ConnectionStatusListener, @@ -466,7 +468,7 @@ def start_server_if_needed(self, return assert self.device.curve == 29, f'curve type {self.device.curve} is not implemented' - assert self.device.protocol == 2, f'protocol {self.device.protocol} is not supported' + assert self.device.protocol in (2, 3), f'protocol {self.device.protocol} is not supported' kw = {} if self._read_timeout is not None: @@ -518,6 +520,26 @@ def set_target_temperature(self, temp: int, callback: callable): self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) + def set_intensity(self, intensity: int, callback: callable): + """Set heater intensity/power level (0=Auto, 1..10).""" + if self.conn is None: + self._logger.error("Cannot set intensity: not connected") + callback(False) + return + + message = HeatIntensityMessage(intensity) + self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) + + def set_window_detection(self, enabled: bool, callback: callable): + """Enable/disable heater open-window detection (type 38).""" + if self.conn is None: + self._logger.error("Cannot set window detection: not connected") + callback(False) + return + + message = WindowDetectionMessage(enabled) + self.conn.enqueue_message(WrappedMessage(message, handler=callback, ack=True)) + def set_child_lock(self, enabled: bool, callback: callable): """Set child lock state.""" _LOGGER.debug("ChildLockMessage: %s", enabled) diff --git a/custom_components/syncleo_kettle/manifest.json b/custom_components/syncleo_kettle/manifest.json index 8560dcd..668830c 100644 --- a/custom_components/syncleo_kettle/manifest.json +++ b/custom_components/syncleo_kettle/manifest.json @@ -4,9 +4,10 @@ "codeowners": ["@samoswall"], "config_flow": true, "dependencies": ["zeroconf"], - "documentation": "https://github.com/your_username/syncleo-kettle-ha", + "documentation": "https://github.com/samoswall/polaris-local", + "issue_tracker": "https://github.com/samoswall/polaris-local/issues", "integration_type": "device", "iot_class": "local_push", "version": "1.0.1", "zeroconf": ["_syncleo._udp.local."] -} \ No newline at end of file +} diff --git a/custom_components/syncleo_kettle/protocol.py b/custom_components/syncleo_kettle/protocol.py index 855eb76..ae0358b 100644 --- a/custom_components/syncleo_kettle/protocol.py +++ b/custom_components/syncleo_kettle/protocol.py @@ -219,7 +219,13 @@ def from_encrypted(buf: bytes, inkey: bytes, outkey: bytes) -> Message: cl = _cl break - m = cl.from_packed_data(data, seq=head.seq) + try: + m = cl.from_packed_data(data, seq=head.seq) + except Exception as exc: + _logger.debug(f'type {type} failed to parse as {cl.__name__} ({exc}); falling back to UnknownMessage') + m = UnknownMessage.from_packed_data(data, seq=head.seq) + m.set_type(type) + return m if isinstance(m, UnknownMessage): m.set_type(type) return m @@ -407,6 +413,50 @@ def _repr_fields(self) -> ReprDict: return {'temperature': self.temperature} +class HeatIntensityMessage(CmdOutgoingMessage): + """Heater intensity / power level (protocol type 15). + + Value 0 = Auto, 1..10 = fixed power level. Outgoing only: incoming type-15 + frames are read from UnknownMessage in the coordinator so kettle parsing is + untouched (the incoming dispatch only scans CmdIncomingMessage subclasses). + Setting a fixed level makes the heater switch to its manual mode (mode 4). + """ + TYPE = 15 + + intensity: int + + def __init__(self, intensity: int, seq: Optional[int] = None): + super().__init__(seq) + self.intensity = intensity + + def pack_data(self) -> bytes: + return self.intensity.to_bytes(1, byteorder='little') + + def _repr_fields(self) -> ReprDict: + return {'intensity': self.intensity} + + +class WindowDetectionMessage(CmdOutgoingMessage): + """Heater open-window detection toggle (protocol type 38): 0=off, 1=on. + + Outgoing only: incoming type-38 frames are read from UnknownMessage in the + coordinator so kettle parsing is untouched. + """ + TYPE = 38 + + value: bool + + def __init__(self, value: bool, seq: Optional[int] = None): + super().__init__(seq) + self.value = value + + def pack_data(self) -> bytes: + return struct.pack(' ReprDict: + return {'window_detection': self.value} + + class PingMessage(CmdIncomingMessage, CmdOutgoingMessage): TYPE = 255 diff --git a/custom_components/syncleo_kettle/sensor.py b/custom_components/syncleo_kettle/sensor.py index d7e8674..7fbd288 100644 --- a/custom_components/syncleo_kettle/sensor.py +++ b/custom_components/syncleo_kettle/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .coordinator import PolarisDataUpdateCoordinator -from .const import DOMAIN, POLARIS_KETTLE_WITH_WEIGHT_TYPE +from .const import DOMAIN, POLARIS_KETTLE_WITH_WEIGHT_TYPE, POLARIS_HEATER_TYPE from .protocol import DeviceHardwareMessage _LOGGER = logging.getLogger(__name__) @@ -33,19 +33,23 @@ async def async_setup_entry( if coordinator.device_info is None: _LOGGER.error("Device info not available, cannot create entity") return - # Добавляем только чайники с весом + is_heater = coordinator.device_info['model_id'] in POLARIS_HEATER_TYPE + + sensors = [CurrentTemperatureSensor(coordinator, config_entry.entry_id)] + + # Device Hardware is a kettle-only diagnostic; on heaters it only ever reads + # "unknown", so skip it for heater-class devices. + if not is_heater: + sensors.append(DeviceHardwareSensor(coordinator, config_entry.entry_id)) + + # Только чайники с весом сообщают вес. if coordinator.device_info['model_id'] in POLARIS_KETTLE_WITH_WEIGHT_TYPE: - sensors = [ - CurrentTemperatureSensor(coordinator, config_entry.entry_id), - DeviceHardwareSensor(coordinator, config_entry.entry_id), - WeightSensor(coordinator, config_entry.entry_id), - ] - else: - sensors = [ - CurrentTemperatureSensor(coordinator, config_entry.entry_id), - DeviceHardwareSensor(coordinator, config_entry.entry_id), - ] - + sensors.append(WeightSensor(coordinator, config_entry.entry_id)) + + # Heaters report an instantaneous power output level (0..10). + if is_heater: + sensors.append(CurrentPowerSensor(coordinator, config_entry.entry_id)) + async_add_entities(sensors) class WeightSensor(SensorEntity): @@ -125,6 +129,43 @@ def should_poll(self) -> bool: """No need to poll, coordinator notifies of updates.""" return False +class CurrentPowerSensor(SensorEntity): + """Heater instantaneous power output level (0..10).""" + + _attr_has_entity_name = True + _attr_name = "Current Power" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:heat-wave" + + def __init__(self, coordinator: PolarisDataUpdateCoordinator, entry_id: str) -> None: + """Initialize the Current Power sensor.""" + self.coordinator = coordinator + self._entry_id = entry_id + self._attr_unique_id = f"{coordinator._mac}_current_power" + self._attr_device_info = coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.get("connected", False) + + @property + def native_value(self) -> int | None: + """Return the current power output level (0..10).""" + return self.coordinator.data.get("current_power") + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def should_poll(self) -> bool: + """No need to poll, coordinator notifies of updates.""" + return False + + class DeviceHardwareSensor(SensorEntity): """Representation of a Device Hardware sensor.""" @@ -191,4 +232,4 @@ async def async_added_to_hass(self) -> None: @property def should_poll(self) -> bool: """No need to poll, coordinator notifies of updates.""" - return False \ No newline at end of file + return False \ No newline at end of file diff --git a/custom_components/syncleo_kettle/switch.py b/custom_components/syncleo_kettle/switch.py index 1b4a8ba..7b873db 100644 --- a/custom_components/syncleo_kettle/switch.py +++ b/custom_components/syncleo_kettle/switch.py @@ -10,7 +10,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .coordinator import PolarisDataUpdateCoordinator -from .const import DOMAIN, POLARIS_KETTLE_WITH_BACKLIGHT_TYPE +from .const import DOMAIN, POLARIS_KETTLE_WITH_BACKLIGHT_TYPE, POLARIS_HEATER_TYPE _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) @@ -22,19 +22,24 @@ async def async_setup_entry( ) -> None: """Set up Syncleo Kettle switch platform from config entry.""" coordinator: PolarisDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - # Добавляем только тем чайникам, у которых есть подсветка - if coordinator.device_info['model_id'] in POLARIS_KETTLE_WITH_BACKLIGHT_TYPE: - switches = [ - ChildLockSwitch(coordinator, config_entry.entry_id), - VolumeSwitch(coordinator, config_entry.entry_id), - BacklightSwitch(coordinator, config_entry.entry_id), - ] - else: - switches = [ - ChildLockSwitch(coordinator, config_entry.entry_id), - VolumeSwitch(coordinator, config_entry.entry_id), - ] - + is_heater = coordinator.device_info['model_id'] in POLARIS_HEATER_TYPE + + # Child lock and sound (volume) are common to kettles and heaters. + switches = [ + ChildLockSwitch(coordinator, config_entry.entry_id), + VolumeSwitch(coordinator, config_entry.entry_id), + ] + + # Backlight/display: kettles that expose it, plus heaters (configurable display). + if is_heater or coordinator.device_info['model_id'] in POLARIS_KETTLE_WITH_BACKLIGHT_TYPE: + switches.append(BacklightSwitch(coordinator, config_entry.entry_id)) + + # Heaters expose open-window detection. Power on/off is intentionally NOT + # exposed as a switch: the climate entity already provides HEAT/OFF via the + # same underlying power command, so a separate PowerSwitch would be redundant. + if is_heater: + switches.append(WindowDetectionSwitch(coordinator, config_entry.entry_id)) + async_add_entities(switches) class ChildLockSwitch(SwitchEntity): @@ -162,3 +167,47 @@ async def async_added_to_hass(self) -> None: def should_poll(self) -> bool: """No need to poll, coordinator notifies of updates.""" return False + + +class WindowDetectionSwitch(SwitchEntity): + """Toggle for the heater's open-window detection.""" + + _attr_has_entity_name = True + _attr_translation_key = "window_detection" + _attr_icon = "mdi:window-open-variant" + + def __init__(self, coordinator: PolarisDataUpdateCoordinator, entry_id: str) -> None: + """Initialize the window detection switch.""" + self.coordinator = coordinator + self._entry_id = entry_id + self._attr_unique_id = f"{coordinator._mac}_window_detection" + self._attr_device_info = coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.get("connected", False) + + @property + def is_on(self) -> bool: + """Return true if open-window detection is enabled.""" + return self.coordinator.data.get("window_detection", False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable open-window detection.""" + await self.coordinator.async_set_window_detection(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable open-window detection.""" + await self.coordinator.async_set_window_detection(False) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def should_poll(self) -> bool: + """No need to poll, coordinator notifies of updates.""" + return False diff --git a/custom_components/syncleo_kettle/translations/en.json b/custom_components/syncleo_kettle/translations/en.json index 164eb61..85a6996 100644 --- a/custom_components/syncleo_kettle/translations/en.json +++ b/custom_components/syncleo_kettle/translations/en.json @@ -23,13 +23,29 @@ "entity": { "switch": { "child_lock": { - "name": "Child--Lock" + "name": "Child Lock" }, "volume": { "name": "Volume" }, "backlight": { "name": "Backlight" + }, + "window_detection": { + "name": "Open Window Detection" + } + }, + "climate": { + "heater": { + "state_attributes": { + "preset_mode": { + "state": { + "comfort": "Comfort", + "eco": "Eco", + "away": "Anti frost" + } + } + } } }, "water_heater": { @@ -43,4 +59,4 @@ } } } -} \ No newline at end of file +} \ No newline at end of file diff --git a/custom_components/syncleo_kettle/translations/ru.json b/custom_components/syncleo_kettle/translations/ru.json index fe96ff3..461018c 100644 --- a/custom_components/syncleo_kettle/translations/ru.json +++ b/custom_components/syncleo_kettle/translations/ru.json @@ -30,6 +30,22 @@ }, "backlight": { "name": "Подсветка" + }, + "window_detection": { + "name": "Определение открытого окна" + } + }, + "climate": { + "heater": { + "state_attributes": { + "preset_mode": { + "state": { + "comfort": "Комфорт", + "eco": "Эко", + "away": "Антизамерзание" + } + } + } } }, "water_heater": { @@ -52,4 +68,4 @@ } } } -} \ No newline at end of file +} \ No newline at end of file