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
2 changes: 1 addition & 1 deletion pyit600/__version__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Asynchronous Python client for Salus iT600 smart devices."""

__version__ = "0.5.1"
__version__ = "0.5.2"
75 changes: 73 additions & 2 deletions pyit600/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ async def connect(self) -> str:
return gateway["sGateway"]["NetworkLANMAC"]
except IT600ConnectionError as ae:
try:
with async_timeout.timeout(self._request_timeout):
async with async_timeout.timeout(self._request_timeout):
await self._session.get(f"http://{self._host}:{self._port}/")
except Exception:
raise IT600ConnectionError(
Expand Down Expand Up @@ -504,9 +504,22 @@ async def _refresh_climate_devices(self, devices: List[Any], send_callback=False
if model is not None and "SQ610" in model:
current_humidity = th.get("SunnySetpoint_x100", None) # Quantum thermostats store humidity there, other thermostats store there one of the setpoint temperatures

# Comfort warm floor level (0=off, 1-3=level) is encoded in
# byte 39 of the "Schedule" blob. It only applies to underfloor
# heating setups, so it is exposed only for thermostats that are
# NOT paired with a radiator valve (TRV); for radiator + TRV
# thermostats the feature is meaningless and stays at 0.
paired_trv = th.get("PairedTRVShortID", "") or ""
has_trv = bool(paired_trv) and not paired_trv.upper().startswith("FFFF")
schedule = th.get("Schedule")
comfort_floor_level = None
if not has_trv and isinstance(schedule, str) and len(schedule) >= 80:
comfort_floor_level = bytes.fromhex(schedule)[39]

device = ClimateDevice(
**global_args,
current_humidity=current_humidity,
comfort_floor_level=comfort_floor_level,
current_temperature=th["LocalTemperature_x100"] / 100,
target_temperature=th["HeatingSetpoint_x100"] / 100,
max_temp=th.get("MaxHeatSetpoint_x100", 3500) / 100,
Expand All @@ -528,6 +541,7 @@ async def _refresh_climate_devices(self, devices: List[Any], send_callback=False
device = ClimateDevice(
**global_args,
current_humidity=None,
comfort_floor_level=None,
current_temperature=ther["LocalTemperature_x100"] / 100,
target_temperature=(ther["HeatingSetpoint_x100"] / 100) if is_heating else (ther["CoolingSetpoint_x100"] / 100),
max_temp=(ther.get("MaxHeatSetpoint_x100", 4000) / 100) if is_heating else (ther.get("MaxCoolSetpoint_x100", 4000) / 100),
Expand Down Expand Up @@ -873,6 +887,63 @@ async def set_climate_device_temperature(self, device_id: str, setpoint_celsius:
},
)

async def set_climate_device_comfort_floor_level(self, device_id: str, level: int) -> None:
"""Set the comfort warm floor level (0=off, 1-3=level).

The level is sent through the sIT600I:SetCommand_d command channel as a
copy of the thermostat's current "Schedule" blob (which encodes the level
at byte 39) with an incremented sequence counter. The two trailing bytes
depend only on the sequence counter (they are not a content checksum).
Reverse-engineered from the official Salus app's local API traffic.
"""
if level not in (0, 1, 2, 3):
raise ValueError("comfort floor level must be 0, 1, 2 or 3")

device = self.get_climate_device(device_id)
if device is None:
_LOGGER.error("Cannot set comfort floor level: climate device not found: %s", device_id)
return

# Read the thermostat's current Schedule blob to reuse its weekly program.
status = await self._make_encrypted_request(
"read",
{"requestAttr": "deviceid", "id": [{"data": device.data}]},
)
th = next((d.get("sIT600TH", {}) for d in status.get("id", [])
if d.get("data", {}).get("UniID") == device.unique_id), {})
schedule = th.get("Schedule")
if not isinstance(schedule, str) or len(schedule) < 80:
_LOGGER.error("Cannot set comfort floor level: thermostat %s does not expose the feature", device_id)
return

base = bytearray.fromhex(schedule)
seq = (base[1] + 1) & 0xff # app increments the sequence counter on each command

body = bytearray()
body.append(0x73) # command marker (Schedule uses 0x72)
body.append(seq) # sequence counter
body += base[2:39] # weekly program (unchanged)
body.append(level & 0xff) # comfort floor level @ byte 39
body.append((2 * seq - 1) & 0xff) # trailing byte 0 (derived from seq)
body.append((255 - (seq >> 2)) & 0xff) # trailing byte 1 (derived from seq)
body += bytes.fromhex("020000000100") # fixed footer
body += b"\xff" * 12 # command tail padding

command = "423131" + body.hex() # "B11" prefix + body

await self._make_encrypted_request(
"write",
{
"requestAttr": "write",
"id": [
{
"data": device.data,
"sIT600I": {"SetCommand_d": command},
}
],
},
)

@staticmethod
def round_to_half(number: float) -> float:
"""Rounds number to half of the integer (eg. 1.01 -> 1, 1.4 -> 1.5, 1.8 -> 2)"""
Expand Down Expand Up @@ -919,7 +990,7 @@ async def _make_encrypted_request(self, command: str, request_body: dict) -> Any
if self._debug:
_LOGGER.debug("Gateway request: POST %s\n%s\n", request_url, request_body_json)

with async_timeout.timeout(self._request_timeout):
async with async_timeout.timeout(self._request_timeout):
resp = await self._session.post(
request_url,
data=self._encryptor.encrypt(request_body_json),
Expand Down
1 change: 1 addition & 0 deletions pyit600/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ClimateDevice(NamedTuple):
manufacturer: str
model: Optional[str]
sw_version: Optional[str]
comfort_floor_level: Optional[int]


class BinarySensorDevice(NamedTuple):
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def read(*parts):
name="pyit600",
packages=find_packages(include=["pyit600"]),
test_suite="tests",
url="https://github.com/jvitkauskas/pyit600",
url="https://github.com/bartoszp/pyit600",
version=get_version(),
zip_safe=False,
python_requires='>=3.7',
Expand Down