Skip to content

Architecture

Climate Orchestrator exposes a single, whole-home smart climate entity that orchestrates every radiator valve (TRV) and air conditioner in the house. It picks the right device(s) to run based on per-area sensors and a home-wide average, coordinates heating against cooling so they never fight, uses humidity for comfort, and drives TRVs with a Model Predictive Controller. It is built test-first with modern tooling.

  • Domain: climate_orchestrator
  • Target Home Assistant: 2025.2+ (matching current pytest-homeassistant-custom-component support)

Target hardware

The integration is built around two classes of climate device, distinguished by how each can be steered:

Class Representative devices Control levers
Radiator valve (TRV) Zigbee thermostatic valves such as the SONOFF TRVZB (via Zigbee2MQTT) valve opening % (0–100), local_temperature_calibration, and a normal setpoint
Air conditioner mini-split / window / portable units (e.g. Midea-class) setpoint, mode, fan, swing — setpoint-only, no sensor-offset lever

Design implication: TRVs support both valve control and sensor-offset calibration; an AC supports neither — only setpoint, mode, fan, swing. The device abstraction accommodates both control philosophies (see Device control). Any HA climate entity in these classes works: the adapter reads each device's reported capabilities (heat/cool/dry, setpoint range and step) at runtime and drives only what it actually supports.

High-level architecture

flowchart TD
    CE["climate.climate_orchestrator<br>(the single whole-home entity)"]
    COMP["Companion entities<br>switch / number / select / sensor<br>(feature flags & tunables)"]
    CO["SmartClimateCoordinator<br>(DataUpdateCoordinator)<br>shared runtime state · one control cycle per trigger"]
    CE -- "target, mode, preset" --> CO
    COMP -- "live settings" --> CO
    CO -- "sensor data" --> SN["sensing.build_snapshot<br>area→sensor map<br>staleness guard<br>home-wide average"]
    CO -- "control law" --> EN["control.engine.decide<br>comfort index<br>hysteresis + guards"]
    CO -- "device commands" --> AD["ClimateAdapter / devices<br>TRV: MPC valve or local offset<br>AC: setpoint bias"]
Hold "Alt" / "Option" to enable pan & zoom

Why a coordinator. A single DataUpdateCoordinator (SmartClimateCoordinator) owns the shared runtime state (current sensor readings, aggregates, learned MPC state, feature-flag values) and runs one control cycle per trigger. All entities (the climate entity, switches, numbers, sensors) read from the coordinator. This keeps the ClimateEntity thin — no god-object entity — and pushes the logic into testable, dependency-light modules (the pure control/ package, the sensing/ snapshot builder, and per-TRV MpcControllers), with all device I/O behind a ClimateAdapter.

Control cycle triggers. A control cycle runs on: any subscribed sensor change, any managed-device state change, target/preset/mode change, a feature-flag entity change, and a periodic keepalive (UPDATE_INTERVAL_SECONDS, default 60 s, to re-assert offsets and run MPC).

One control cycle

flowchart TD
    T["Trigger<br>sensor / device / setting change<br>or 60 s keepalive"] --> S
    S["build_snapshot<br>area→sensor resolution<br>staleness guard"] --> A
    A["Aggregation<br>home-wide average (or user override)<br>comfort index → effective temperatures"] --> B
    B["Desired band<br>preset or manual edges + per-area offset<br>+ adaptive cooling-comfort shift"] --> E
    E["control.engine.decide (pure)<br>per-device hysteresis latch<br>guards: frost > window > outdoor gating"] --> D{"per device"}
    D -- "TRV in mpc mode" --> X["MPC: observe + identify + optimise<br>scipy, in an executor job<br>(async_add_executor_job)"]
    D -- "TRV target/offset · AC" --> C["devices/command.py<br>decision → DeviceCommand<br>(AC bias + proportional anchor)"]
    X --> R
    C --> R["devices/reconcile.py<br>step-diff against last-known state<br>+ AC setpoint throttle"]
    R --> W["Writes via ClimateAdapter<br>per device, gathered with<br>return_exceptions=True"]
Hold "Alt" / "Option" to enable pan & zoom

The scipy hop matters: system identification and optimisation are synchronous numerical code, so the coordinator runs the MPC math in an executor job (hass.async_add_executor_job), keeping the event loop unblocked. Each TRV has its own controller, so concurrent jobs never share state.

Repository layout

climate_orchestrator/
├── custom_components/
│   └── climate_orchestrator/
│       ├── __init__.py            # setup, coordinator wiring, platform forwarding, services
│       ├── manifest.json
│       ├── const.py               # domain, defaults, presets, tuning constants
│       ├── models.py              # SmartClimateData, DeviceReading, Band, Status (pure value objects)
│       ├── config_flow.py         # config + options flow (TRVs/ACs, outdoor sensor, weather entity, hints)
│       ├── brand/                 # in-integration icon.png/icon@2x.png (HA >= 2026.3 local brands)
│       ├── coordinator.py         # SmartClimateCoordinator: snapshot + control cycle + persistence
│       ├── events.py              # EventBridge: edge-triggered bus events + bell notifications
│       ├── supervision.py         # DeviceSupervisor: command-ignored watchdog + manual-override takeover
│       ├── repairs.py             # repair-issue helpers (raise/clear Repairs notices)
│       ├── windows.py             # WindowMonitor: per-area window debounce + grace-delay rechecks
│       ├── persistence.py         # LearnedStateStores: schema-versioned stores + flash-wear limiter
│       ├── adaptation.py          # WeatherAdaptation: rmot, adaptive band, forecast cache
│       ├── settings.py            # NumberSetting/SwitchSetting registries + RuntimeSettings resolver
│       ├── entity.py              # shared base entity + hub DeviceInfo
│       ├── diagnostics.py         # downloadable diagnostics dump
│       ├── control/
│       │   ├── engine.py          # arbitration: guards + capability gating + per-area offset (pure)
│       │   ├── hysteresis.py      # OR-engage / AND-release demand law (pure)
│       │   ├── comfort.py         # apparent temperature + dew point (pure)
│       │   ├── adaptive_comfort.py# running-mean outdoor + saturating cool-edge relaxation (pure)
│       │   ├── adaptive_bias.py   # self-tuning AC setpoint bias (integral feedback) (pure)
│       │   ├── window.py          # window-open suppression + grace debounce (pure)
│       │   ├── slope.py           # least-squares home temperature slope (pure)
│       │   ├── throttle.py        # AC setpoint write throttling (pure)
│       │   ├── forecast.py        # hourly→per-step forecast expansion for preconditioning (pure)
│       │   ├── numeric.py         # shared clamp() helper (pure)
│       │   ├── runtime_stats.py   # runtime-fraction + cycles/hour integrals (pure)
│       │   └── mpc/
│       │       ├── model.py       # first-order thermal model + system ID (scipy)
│       │       ├── observer.py    # Kalman state estimate
│       │       ├── optimizer.py   # receding-horizon valve solve; scalar or forecast series (scipy)
│       │       └── controller.py  # stateful per-room MPC (observe + optimise + persist)
│       ├── sensing/
│       │   ├── registry.py        # area matching, snapshot build, staleness guard
│       │   └── aggregate.py       # mean-or-none / slope helpers (pure)
│       ├── devices/
│       │   ├── adapter.py         # ClimateAdapter: capabilities + read/apply over HA climate services
│       │   ├── model.py           # DeviceCommand / Mode / AdapterCapabilities (pure)
│       │   ├── command.py         # decision → device command (pure)
│       │   ├── reconcile.py       # step-diff command minimisation (pure)
│       │   └── trv.py             # TRV number discovery + local-offset helpers
│       ├── climate.py             # the single whole-home ClimateEntity
│       ├── sensor.py / binary_sensor.py / switch.py / number.py / select.py
│       ├── icons.json / strings.json
│       └── translations/en.json
├── tests/                         # unit/ (mirrors the package) + ha/ (hass fixtures)
├── pyproject.toml                 # uv-managed, ruff + mypy + pytest config
├── .github/workflows/ci.yml       # ruff, mypy, pytest+coverage, hassfest, HACS (GitHub Actions)
├── .github/workflows/release.yml  # python-semantic-release on green CI
├── hacs.json
├── CHANGELOG.md
└── README.md

Next: Sensing — how each device's readings and the home-wide average are built.