Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
| `set_setback_temperature(...)` | Write the setback temperature (untested). |
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
| `set_hot_water_*(...)` / `*_heat_pump_hot_water_schedule(...)` | Hot-water cylinder surface (untested — no hardware). |
| `set_hot_water_holiday(hub_id, appliance_ids, until, *, enable, heat_pump)` | Cylinder Holiday on/off (validated on an ASHW cylinder). |
| `get_heat_pump_hot_water_schedule(...)` | ASHW cylinder schedule as `HeatPumpHwcSchedule` (validated). Writing it answers 403. |
| `set_hot_water_*(...)` | Remaining cylinder controls (untested — no hardware). |
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
Expand Down
4 changes: 4 additions & 0 deletions dimplex_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
ApplianceModeStatus,
ApplianceStatus,
AutomaticProvisioning,
HeatPumpHwcPeriod,
HeatPumpHwcSchedule,
Hub,
HygieneFrequency,
ProductModel,
Expand Down Expand Up @@ -83,6 +85,8 @@
"SetbackStatus",
"TimerMode",
"TimerModeSettings",
"HeatPumpHwcPeriod",
"HeatPumpHwcSchedule",
"TimerPeriod",
"AutomaticProvisioning",
"ProductModel",
Expand Down
31 changes: 18 additions & 13 deletions dimplex_controller/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,23 @@ def capabilities_for(
heat_pump = True
hot_water = True

boost = True
away = True
if status is not None and status.AvailableHotWater is not None:
hot_water = True

# A cylinder reports its *water* target in ActiveSetPointTemperature (45 °C
# on a live ASHW cylinder), so only a room temperature makes it room climate.
# Without one, the room-heater features default to off — a live cylinder
# sends null for every one of them — unless its status reports them.
climate = not hot_water or (status is not None and status.RoomTemperature is not None)

boost = climate
away = climate
advance = True
open_window = True
eco_start = True
setback_read = True
frost = True
open_window = climate
eco_start = climate
setback_read = climate
frost = climate
timer = True
climate = not hot_water # cylinder-only appliances are not room climate

if status is not None:
if status.BoostDuration is not None or status.BoostTemperature is not None:
Expand All @@ -183,10 +191,6 @@ def capabilities_for(
eco_start = True
if status.SetbackEnabled is not None or status.SetbackTemperature is not None:
setback_read = True
if status.AvailableHotWater is not None:
hot_water = True
if status.RoomTemperature is not None or status.ActiveSetPointTemperature is not None:
climate = True

# Advance only means something for a scheduled room heater; a cylinder has
# no "next comfort period" to jump to.
Expand All @@ -200,10 +204,11 @@ def capabilities_for(
open_window=open_window,
eco_start=eco_start,
setback_read=setback_read,
setback_write=True,
setback_write=climate,
frost=frost,
timer=timer,
setpoint_write=True,
# The room setpoint endpoint; cylinders have their own (Set*TemperatureHwc).
setpoint_write=climate,
energy_meter=energy_meter,
storage=storage,
hot_water=hot_water,
Expand Down
54 changes: 49 additions & 5 deletions dimplex_controller/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import json
import logging
import math
import random
from datetime import datetime, timedelta, timezone
from typing import Any
Expand Down Expand Up @@ -36,6 +37,7 @@
ApplianceModeSettings,
ApplianceModeStatus,
ApplianceStatus,
HeatPumpHwcSchedule,
Hub,
HygieneFrequency,
ProductModel,
Expand Down Expand Up @@ -840,6 +842,42 @@ async def set_hot_water_mode(
endpoint=endpoint,
)

async def set_hot_water_holiday(
self,
hub_id: str,
appliance_ids: list[str],
until: datetime | None = None,
*,
enable: bool = True,
heat_pump: bool = False,
) -> None:
"""Put a cylinder on Holiday (standby) until ``until``, or clear it.

Verified on a live ASHW cylinder (EDL200UK-630RF) with ``heat_pump=True``:
engaging moved ``ApplianceModes`` 8192 → 8200 and set
``HolidayNumOfDays``, heating stopped, and clearing restored 8192. The
cylinder counts holidays in whole days, so ``until`` is also sent as a
rounded-up day count; a caller that wants a time of day has to clear
Holiday itself at that time.
"""
days = 0
if enable:
if until is None:
raise ValueError("a holiday needs an end time")
end = until if until.tzinfo is not None else until.replace(tzinfo=timezone.utc)
seconds = (end - datetime.now(timezone.utc)).total_seconds()
days = max(1, math.ceil(seconds / 86400))
endpoint = "/RemoteControl/SetApplianceModeHeatPumpHwc" if heat_pump else "/RemoteControl/SetApplianceModeHwc"
await self.set_mode_flag(
hub_id,
appliance_ids,
ApplianceModeFlag.HOLIDAY,
enable=enable,
until=until,
number_of_days=days,
endpoint=endpoint,
)

async def set_hot_water_boost_temperature(
self, hub_id: str, appliance_ids: list[str], temperature: float, *, enable: bool = True
) -> None:
Expand Down Expand Up @@ -902,23 +940,29 @@ async def set_hot_water_hygiene(
endpoint=endpoint,
)

async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
"""Read an ASHW cylinder's schedule.
async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> HeatPumpHwcSchedule:
"""Read an ASHW cylinder's weekly schedule.

.. warning:: Untested — APK-confirmed only.
Verified on a live ASHW cylinder (EDL200UK-630RF). The reply does not fit
:class:`TimerModeSettings` — there is no ``TimerMode`` and each period is
``{DayOfWeek, StartTime, Mode}`` — hence :class:`HeatPumpHwcSchedule`.
"""
payload = {"HubId": hub_id, "ApplianceId": appliance_id}
data = await self._request(
"POST",
"/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
json=payload,
)
return TimerModeSettings.model_validate(data)
return HeatPumpHwcSchedule.model_validate(data)

async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
"""Write an ASHW cylinder's schedule periods.

.. warning:: Untested — APK-confirmed only.
.. warning:: Does not work yet. On a live ASHW cylinder this endpoint
answered HTTP 403 to every body tried — wrapped in ``TimerModeSettings``
or ``Settings``, with or without ``TimerMode``, integer or ``HH:MM:SS``
times, and app versions up to 3.0.0 — while unwrapped bodies got 400.
The request the official app sends has not been captured.
"""
payload = {"TimerModeSettings": settings.model_dump(mode="json")}
await self._request("POST", "/RemoteControl/UpdateHeatPumpHwcSchedulePeriods", json=payload)
Expand Down
40 changes: 40 additions & 0 deletions dimplex_controller/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,31 @@ class TimerModeSettings(BaseModel):
TimerPeriods: list[TimerPeriod] = Field(default_factory=list)


class HeatPumpHwcPeriod(BaseModel):
"""One period of an ASHW cylinder's weekly schedule.

A live cylinder returns one ``{DayOfWeek, StartTime: 0, Mode: 2}`` per day.
``DayOfWeek`` is .NET's (0 = Sunday). ``StartTime`` is an integer, presumed
minutes after midnight, and what each ``Mode`` value means is not yet known.
"""

DayOfWeek: int
StartTime: int
Mode: int


class HeatPumpHwcSchedule(BaseModel):
"""``ApiGetTimerModeDetailsForHeatPumpHwcAppliance`` reply.

Unlike the room-heater :class:`TimerModeSettings` it has no ``TimerMode``
and its periods carry a mode instead of an end time and temperature.
"""

HubId: str
ApplianceId: str
TimerPeriods: list[HeatPumpHwcPeriod] = Field(default_factory=list)


class UserContext(BaseModel):
Id: str
EmailAddress: str | None = None
Expand Down Expand Up @@ -286,6 +311,21 @@ class ApplianceStatus(BaseModel):
LockStatus: int | None = None
ErrorCode: str | None = None
WarningCode: str | None = None
# Hot-water cylinder fields, as a live ASHW cylinder (EDL200UK-630RF) sends
# them. AvailableHotWater above is litres of usable hot water (the app shows
# it as "336 L Hot water").
Capacity: int | None = None # tank size, litres
HeatingUp: bool | None = None
IsGreenEnergyActive: bool | None = None
HygieneFrequency: int | None = None # EFrequency, see HygieneFrequency
HygieneTemperature: float | None = None
HolidayNumOfDays: int | None = None
# Present in the same reply, always null on the cylinder seen so far.
DelayedStartTime: str | None = None
DsmStatus: int | None = None
ModifiedPower: float | None = None
ConnectedChargeProfiles: Any = None
StandaloneChargeProfiles: Any = None

@field_validator("ErrorCode", "WarningCode", mode="before")
@classmethod
Expand Down
49 changes: 35 additions & 14 deletions docs/decompiled-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,21 +150,39 @@ the control/reporting-relevant ones are listed here. Method names are the
> `SetApplianceSetpointTemperature` for setpoints and `SetApplianceMode`
> (FrostProtect) for "off".

### RemoteControl — hot-water cylinders (❌ not owned — APK-confirmed only)
### RemoteControl — hot-water cylinders (partly validated on an ASHW cylinder)

Applies to `WaterHeater` and heat-pump `ASHW Cylinder` models. All reuse
`SetApplianceModeRequest` / `ApplianceModeSettings`.

| Endpoint | Method | Mode used | 📦 |
| --- | --- | --- | --- |
| `/RemoteControl/SetApplianceModeHwc` | `SetModeForHWAppliances` | Boost/Normal/… | ✅ |
| `/RemoteControl/SetBoostTemperatureHwc` | `SetHWBoostTemperature` | `Boost`, Temperature | ✅ |
| `/RemoteControl/SetNormalTemperatureHwc` | `SetHWNormalTemperature` | `Normal`, Temperature | ✅ |
| `/RemoteControl/SetHygieneSettingsHwc` | `SetHWHygieneSettings` | `Hygiene`, Temperature, `Frequency` | ✅ |
| `/RemoteControl/SetApplianceModeHeatPumpHwc` | `SetModeForHeatPumpHWAppliances` | — | ✅ |
| `/RemoteControl/SetHygieneSettingsHeatPumpHwc` | `SetHeatPumpHWCHygieneSettings` | `Hygiene` | ✅ |
| `/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance` | `ApiGetTimerModeDetailsForHeatPumpHwcAppliance` → details | `{HubId, ApplianceId}` | ✅ |
| `/RemoteControl/UpdateHeatPumpHwcSchedulePeriods` | schedule edit | — | ✅ |
| Endpoint | Method | Mode used | 📦 | 🔬 live (EDL200UK-630RF) |
| --- | --- | --- | --- | --- |
| `/RemoteControl/SetApplianceModeHwc` | `SetModeForHWAppliances` | Boost/Normal/… | ✅ | — not an ASHW endpoint |
| `/RemoteControl/SetBoostTemperatureHwc` | `SetHWBoostTemperature` | `Boost`, Temperature | ✅ | — not tested |
| `/RemoteControl/SetNormalTemperatureHwc` | `SetHWNormalTemperature` | `Normal`, Temperature | ✅ | — not tested |
| `/RemoteControl/SetHygieneSettingsHwc` | `SetHWHygieneSettings` | `Hygiene`, Temperature, `Frequency` | ✅ | — not tested |
| `/RemoteControl/SetApplianceModeHeatPumpHwc` | `SetModeForHeatPumpHWAppliances` | `Holiday` | ✅ | ✅ Holiday on/off (`set_hot_water_holiday`) |
| `/RemoteControl/SetHygieneSettingsHeatPumpHwc` | `SetHeatPumpHWCHygieneSettings` | `Hygiene` | ✅ | — not tested |
| `/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance` | `ApiGetTimerModeDetailsForHeatPumpHwcAppliance` → details | `{HubId, ApplianceId}` | ✅ | ✅ reads (`HeatPumpHwcSchedule`) |
| `/RemoteControl/UpdateHeatPumpHwcSchedulePeriods` | schedule edit | — | ✅ | ❌ HTTP 403 to every body tried |

What a live ASHW cylinder returned:

- **Overview:** `ApplianceModes` 8192 (`Normal`, which the app shows as "Timer"),
`ActiveSetPointTemperature` 45 (the *water* target), `AvailableHotWater` 336 (litres —
the app shows "336 L Hot water"), `Capacity` 200, `HeatingUp`, `IsGreenEnergyActive`,
`HygieneFrequency` 7, `HolidayNumOfDays`, and `ErrorCode`/`WarningCode` as **integers**.
Every room-heater field (`RoomTemperature`, `OpenWindowEnabled`, `EcoStartEnabled`, setback,
away) is `null`.
- **Holiday:** `SetApplianceModeHeatPumpHwc` with `ApplianceModes` = 8, `Status` 1,
`Date` and `NumberOfDays` moved the status to 8200 with `HolidayNumOfDays` set and heating
stopped; `Status` 0 restored 8192.
- **Schedule:** `{HubId, ApplianceId, TimerPeriods: [{DayOfWeek, StartTime: 0, Mode: 2}]}` —
one period per day, no `TimerMode`. What `Mode` values mean, and whether `StartTime` is
minutes after midnight, is not yet known.
- **Schedule write:** wrapped bodies (`TimerModeSettings` or `Settings`, with or without
`TimerMode`, integer or `HH:MM:SS` times, `app_version` up to 3.0.0) all got **403**;
unwrapped bodies got **400**. The app's own request has not been captured.

### Reports / profiles / structure (context)

Expand Down Expand Up @@ -211,10 +229,10 @@ Every ❌ in the "before 0.13.0" column above is now addressed:
| "Off" | `set_frost_protect()` / `turn_off()` |
| Advance / Manual / Eco modes | `set_advance()`, `set_manual()`, `set_eco_mode()` |
| Setback write | `set_setback_temperature()`; capability `setback_write=True` |
| HWC / heat-pump HWC | `set_hot_water_*`, `get/set_heat_pump_hot_water_schedule` — implemented but ❌ untested |
| HWC / heat-pump HWC | `set_hot_water_*`, `get/set_heat_pump_hot_water_schedule` — implemented; Holiday and the schedule read since validated on an ASHW cylinder |
| Temperature range | `MODE_TEMP_MIN`/`MAX` = 7–30 °C for Boost/Frost/Manual/Eco; capability `min_temp` corrected from 5 → 7 |

Still open: live validation of `SetSetbackTemperature` and the entire HWC surface.
Still open: live validation of `SetSetbackTemperature` and the HWC temperature, boost and hygiene writes.

### What 0.13.1 changed

Expand Down Expand Up @@ -254,7 +272,10 @@ Still open: live validation of `SetSetbackTemperature` and the entire HWC surfac
- **Additionally validated live on a QM100RF:** Boost/Away flag values, the
`Status` engage/clear semantics, `BoostDuration`, and the dedicated
`SetApplianceSetpointTemperature` endpoint.
- **APK-only (no hardware):** all HWC / heat-pump-HWC endpoints and `SetSetbackTemperature`.
- **Additionally validated live on an ASHW cylinder (EDL200UK-630RF):** the cylinder overview
fields, Holiday on/off via `SetApplianceModeHeatPumpHwc`, and the heat-pump schedule read.
`UpdateHeatPumpHwcSchedulePeriods` answers 403.
- **APK-only (no hardware):** the remaining HWC endpoints and `SetSetbackTemperature`.
- **Resolved:** whether a *raised* Away target (>7) is honoured on Quantum — it is.
Away is a settable **7–18 °C** setback that *defaults* to 7 (anti-freeze), so the
common case is a low setpoint by design; a higher chosen value applies up to 18,
Expand Down
6 changes: 3 additions & 3 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ paths:

/RemoteControl/SetApplianceModeHeatPumpHwc:
post:
summary: Set an ASHW (heat-pump) cylinder mode (untested)
summary: Set an ASHW (heat-pump) cylinder mode (Holiday validated live)
requestBody:
$ref: "#/components/requestBodies/ApplianceModeRequest"
responses:
Expand All @@ -651,7 +651,7 @@ paths:

/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance:
post:
summary: Read an ASHW cylinder's schedule (untested)
summary: Read an ASHW cylinder's schedule (validated live)
requestBody:
required: true
content:
Expand All @@ -673,7 +673,7 @@ paths:

/RemoteControl/UpdateHeatPumpHwcSchedulePeriods:
post:
summary: Write an ASHW cylinder's schedule periods (untested)
summary: Write an ASHW cylinder's schedule periods (answers 403 on a live cylinder)
requestBody:
required: true
content:
Expand Down
Loading
Loading