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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
push_err.txt
4 changes: 2 additions & 2 deletions custom_components/syncleo_kettle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
return unload_ok
157 changes: 115 additions & 42 deletions custom_components/syncleo_kettle/climate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Climate platform for Syncleo Kettle."""
"""Climate platform for Syncleo heater-class devices (RusClimate convectors)."""
from __future__ import annotations

import logging
Expand All @@ -7,55 +7,77 @@
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
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:
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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
return False
32 changes: 32 additions & 0 deletions custom_components/syncleo_kettle/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
53 changes: 48 additions & 5 deletions custom_components/syncleo_kettle/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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),
)

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
await self._hass.async_add_executor_job(set_color_night)
Loading