Skip to content
This repository has been archived by the owner on Mar 19, 2024. It is now read-only.

Commit

Permalink
Senso compatibility (#194)
Browse files Browse the repository at this point in the history
  • Loading branch information
xaviergriffon authored Mar 7, 2023
1 parent c69f5de commit decf43c
Show file tree
Hide file tree
Showing 26 changed files with 194 additions and 84 deletions.
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,19 @@

Ideas are welcome ! Don't hesitate to create issue to suggest something, it will be really appreciated.

**This integration is NOT compatible with sensoAPP, only with multiMATIC app.**

**This integration is NOT likely to be compatible with VR921 (even if you use multiMATIC app). You may still have
some entities, but not all.**
**This integration is also compatible with sensoAPP and has been tested with the vr920 and vr921 devices.**

## Installations
- Through HACS [custom repositories](https://hacs.xyz/docs/faq/custom_repositories/) !
- Otherwise, download the zip from the latest release and copy `multimatic` folder and put it inside your `custom_components` folder.

You can configure it through the UI using integration.
You have to provide your username and password (same as multimatic app), if you have multiple serial numbers, you can choose for which number serial number you want the integration.
You have to provide your username and password (same as multimatic or senso app), if you have multiple serial numbers, you can choose for which number serial number you want the integration.
You can create multiple instance of the integration with different serial number (**This is still a beta feature**).

**It is strongly recommended using a dedicated user for HA**, for 2 reasons:
- As usual for security reason, if your HA got compromised somehow, you know which user to block
- I cannot confirm it, but it seems multimatic API only accept the same user to be connected at the same time
- I cannot confirm it, but it seems multimatic and senso API only accept the same user to be connected at the same time

## Changelog
See [releases details](https://github.com/thomasgermain/vaillant-component/releases)
Expand Down
17 changes: 16 additions & 1 deletion custom_components/multimatic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from pymultimatic.api import defaults

from .const import (
CONF_SERIAL_NUMBER,
Expand All @@ -15,7 +16,7 @@
DEFAULT_SCAN_INTERVAL,
DOMAIN,
PLATFORMS,
SERVICES_HANDLER,
SERVICES_HANDLER, CONF_APPLICATION,
)
from .coordinator import MultimaticApi, MultimaticCoordinator
from .service import SERVICES, MultimaticServiceHandler
Expand Down Expand Up @@ -127,3 +128,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Remaining data for multimatic %s", hass.data[DOMAIN])

return unload_ok


async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
new = {**config_entry.data, CONF_APPLICATION: defaults.MULTIMATIC}

config_entry.version = 2
hass.config_entries.async_update_entry(config_entry, data=new)

_LOGGER.debug("Migration to version %s successful", config_entry.version)

return True
171 changes: 125 additions & 46 deletions custom_components/multimatic/climate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Interfaces with Multimatic climate."""
from __future__ import annotations

import abc
from abc import ABC, abstractmethod
from collections.abc import Mapping
import logging
from typing import Any
Expand Down Expand Up @@ -49,8 +49,10 @@
PRESET_QUICK_VETO,
PRESET_SYSTEM_OFF,
ROOMS,
SENSO,
VENTILATION,
ZONES,
CONF_APPLICATION,
)
from .coordinator import MultimaticCoordinator
from .entities import MultimaticEntity
Expand All @@ -74,11 +76,12 @@ async def async_setup_entry(
zones_coo = get_coordinator(hass, ZONES, entry.entry_id)
rooms_coo = get_coordinator(hass, ROOMS, entry.entry_id)
ventilation_coo = get_coordinator(hass, VENTILATION, entry.entry_id)
system_application = SENSO if entry.data[CONF_APPLICATION] == SENSO else MULTIMATIC

if zones_coo.data:
for zone in zones_coo.data:
if not zone.rbr and zone.enabled:
climates.append(ZoneClimate(zones_coo, zone, ventilation_coo.data))
climates.append(build_zone_climate(zones_coo, zone, ventilation_coo.data, system_application))

if rooms_coo.data:
rbr_zone = next((zone for zone in zones_coo.data if zone.rbr), None)
Expand All @@ -103,7 +106,7 @@ async def async_setup_entry(
)


class MultimaticClimate(MultimaticEntity, ClimateEntity, abc.ABC):
class MultimaticClimate(MultimaticEntity, ClimateEntity, ABC):
"""Base class for climate."""

def __init__(
Expand Down Expand Up @@ -131,7 +134,7 @@ def active_mode(self) -> ActiveMode:
return self.coordinator.api.get_active_mode(self.component)

@property
@abc.abstractmethod
@abstractmethod
def component(self) -> Component:
"""Return the room or the zone."""

Expand Down Expand Up @@ -362,52 +365,22 @@ def current_humidity(self) -> int | None:
return int(humidity) if humidity is not None else None


class ZoneClimate(MultimaticClimate):
"""Climate for a zone."""
def build_zone_climate(coordinator: MultimaticCoordinator, zone: Zone, ventilation, application) -> AbstractZoneClimate:
if application == MULTIMATIC:
return ZoneClimate(coordinator, zone, ventilation)
return ZoneClimateSenso(coordinator, zone, ventilation)

_MULTIMATIC_TO_HA: dict[Mode, list] = {
OperatingModes.AUTO: [HVACMode.AUTO, PRESET_COMFORT],
OperatingModes.DAY: [None, PRESET_DAY],
OperatingModes.NIGHT: [None, PRESET_SLEEP],
OperatingModes.OFF: [HVACMode.OFF, PRESET_NONE],
OperatingModes.ON: [None, PRESET_COOLING_ON],
OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO],
QuickModes.ONE_DAY_AT_HOME: [HVACMode.AUTO, PRESET_HOME],
QuickModes.PARTY: [None, PRESET_PARTY],
QuickModes.VENTILATION_BOOST: [HVACMode.FAN_ONLY, PRESET_NONE],
QuickModes.ONE_DAY_AWAY: [HVACMode.OFF, PRESET_AWAY],
QuickModes.SYSTEM_OFF: [HVACMode.OFF, PRESET_SYSTEM_OFF],
QuickModes.HOLIDAY: [HVACMode.OFF, PRESET_HOLIDAY],
QuickModes.COOLING_FOR_X_DAYS: [None, PRESET_COOLING_FOR_X_DAYS],
}

_HA_MODE_TO_MULTIMATIC = {
HVACMode.AUTO: OperatingModes.AUTO,
HVACMode.OFF: OperatingModes.OFF,
HVACMode.FAN_ONLY: QuickModes.VENTILATION_BOOST,
HVACMode.COOL: QuickModes.COOLING_FOR_X_DAYS,
}

_HA_PRESET_TO_MULTIMATIC = {
PRESET_COMFORT: OperatingModes.AUTO,
PRESET_DAY: OperatingModes.DAY,
PRESET_SLEEP: OperatingModes.NIGHT,
PRESET_COOLING_ON: OperatingModes.ON,
PRESET_HOME: QuickModes.ONE_DAY_AT_HOME,
PRESET_PARTY: QuickModes.PARTY,
PRESET_AWAY: QuickModes.ONE_DAY_AWAY,
PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF,
PRESET_COOLING_FOR_X_DAYS: QuickModes.COOLING_FOR_X_DAYS,
}

class AbstractZoneClimate(MultimaticClimate, ABC):
"""Abstract class for a climate for a zone."""
def __init__(
self, coordinator: MultimaticCoordinator, zone: Zone, ventilation
) -> None:
"""Initialize entity."""
super().__init__(coordinator, zone.id)

self._supported_hvac = list(ZoneClimate._HA_MODE_TO_MULTIMATIC.keys())
self._supported_presets = list(ZoneClimate._HA_PRESET_TO_MULTIMATIC.keys())
self._supported_hvac = list(self._ha_mode().keys())
self._supported_presets = list(self._ha_preset().keys())

if not zone.cooling:
self._supported_presets.remove(PRESET_COOLING_ON)
Expand All @@ -419,6 +392,18 @@ def __init__(

self._zone_id = zone.id

@abstractmethod
def _ha_mode(self):
pass

@abstractmethod
def _multimatic_mode(self):
pass

@abstractmethod
def _ha_preset(self):
pass

@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes."""
Expand All @@ -438,7 +423,7 @@ def component(self) -> Zone:
def hvac_mode(self) -> HVACMode:
"""Get the hvac mode based on multimatic mode."""
current_mode = self.active_mode.current
hvac_mode = ZoneClimate._MULTIMATIC_TO_HA[current_mode][0]
hvac_mode = self._multimatic_mode()[current_mode][0]
if not hvac_mode:
if (
current_mode
Expand Down Expand Up @@ -497,7 +482,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None:

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
mode = ZoneClimate._HA_MODE_TO_MULTIMATIC[hvac_mode]
mode = self._ha_mode()[hvac_mode]
await self.coordinator.api.set_zone_operating_mode(self, mode)

@property
Expand All @@ -511,7 +496,7 @@ def hvac_action(self) -> str | None:
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""
return ZoneClimate._MULTIMATIC_TO_HA[self.active_mode.current][1]
return self._multimatic_mode()[self.active_mode.current][1]

@property
def preset_modes(self) -> list[str] | None:
Expand All @@ -522,5 +507,99 @@ def preset_modes(self) -> list[str] | None:

async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
mode = ZoneClimate._HA_PRESET_TO_MULTIMATIC[preset_mode]
mode = self._ha_preset()[preset_mode]
await self.coordinator.api.set_zone_operating_mode(self, mode)

class ZoneClimate(AbstractZoneClimate):
"""Climate for a MULTIMATIC zone."""

_MULTIMATIC_TO_HA: dict[Mode, list] = {
OperatingModes.AUTO: [HVACMode.AUTO, PRESET_COMFORT],
OperatingModes.DAY: [None, PRESET_DAY],
OperatingModes.NIGHT: [None, PRESET_SLEEP],
OperatingModes.OFF: [HVACMode.OFF, PRESET_NONE],
OperatingModes.ON: [None, PRESET_COOLING_ON],
OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO],
QuickModes.ONE_DAY_AT_HOME: [HVACMode.AUTO, PRESET_HOME],
QuickModes.PARTY: [None, PRESET_PARTY],
QuickModes.VENTILATION_BOOST: [HVACMode.FAN_ONLY, PRESET_NONE],
QuickModes.ONE_DAY_AWAY: [HVACMode.OFF, PRESET_AWAY],
QuickModes.SYSTEM_OFF: [HVACMode.OFF, PRESET_SYSTEM_OFF],
QuickModes.HOLIDAY: [HVACMode.OFF, PRESET_HOLIDAY],
QuickModes.COOLING_FOR_X_DAYS: [None, PRESET_COOLING_FOR_X_DAYS],
}

_HA_MODE_TO_MULTIMATIC = {
HVACMode.AUTO: OperatingModes.AUTO,
HVACMode.OFF: OperatingModes.OFF,
HVACMode.FAN_ONLY: QuickModes.VENTILATION_BOOST,
HVACMode.COOL: QuickModes.COOLING_FOR_X_DAYS,
}

_HA_PRESET_TO_MULTIMATIC = {
PRESET_COMFORT: OperatingModes.AUTO,
PRESET_DAY: OperatingModes.DAY,
PRESET_SLEEP: OperatingModes.NIGHT,
PRESET_COOLING_ON: OperatingModes.ON,
PRESET_HOME: QuickModes.ONE_DAY_AT_HOME,
PRESET_PARTY: QuickModes.PARTY,
PRESET_AWAY: QuickModes.ONE_DAY_AWAY,
PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF,
PRESET_COOLING_FOR_X_DAYS: QuickModes.COOLING_FOR_X_DAYS,
}

def _ha_mode(self):
return ZoneClimate._HA_MODE_TO_MULTIMATIC

def _multimatic_mode(self):
return ZoneClimate._MULTIMATIC_TO_HA

def _ha_preset(self):
return ZoneClimate._HA_PRESET_TO_MULTIMATIC


class ZoneClimateSenso(AbstractZoneClimate):
"""Climate for a SENSO zone."""

_SENSO_TO_HA: dict[Mode, list] = {
OperatingModes.TIME_CONTROLLED: [HVACMode.AUTO, PRESET_COMFORT],
OperatingModes.DAY: [None, PRESET_DAY],
OperatingModes.NIGHT: [None, PRESET_SLEEP],
OperatingModes.OFF: [HVACMode.OFF, PRESET_NONE],
OperatingModes.MANUAL: [None, PRESET_COOLING_ON],
OperatingModes.QUICK_VETO: [None, PRESET_QUICK_VETO],
QuickModes.ONE_DAY_AT_HOME: [HVACMode.AUTO, PRESET_HOME],
QuickModes.PARTY: [None, PRESET_PARTY],
QuickModes.VENTILATION_BOOST: [HVACMode.FAN_ONLY, PRESET_NONE],
QuickModes.ONE_DAY_AWAY: [HVACMode.OFF, PRESET_AWAY],
QuickModes.SYSTEM_OFF: [HVACMode.OFF, PRESET_SYSTEM_OFF],
QuickModes.HOLIDAY: [HVACMode.OFF, PRESET_HOLIDAY],
QuickModes.COOLING_FOR_X_DAYS: [None, PRESET_COOLING_FOR_X_DAYS],
}
_HA_MODE_TO_SENSO = {
HVACMode.AUTO: OperatingModes.TIME_CONTROLLED,
HVACMode.OFF: OperatingModes.OFF,
HVACMode.FAN_ONLY: QuickModes.VENTILATION_BOOST,
HVACMode.COOL: QuickModes.COOLING_FOR_X_DAYS,
}

_HA_PRESET_TO_SENSO = {
PRESET_COMFORT: OperatingModes.TIME_CONTROLLED,
PRESET_DAY: OperatingModes.DAY,
PRESET_SLEEP: OperatingModes.NIGHT,
PRESET_COOLING_ON: OperatingModes.MANUAL,
PRESET_HOME: QuickModes.ONE_DAY_AT_HOME,
PRESET_PARTY: QuickModes.PARTY,
PRESET_AWAY: QuickModes.ONE_DAY_AWAY,
PRESET_SYSTEM_OFF: QuickModes.SYSTEM_OFF,
PRESET_COOLING_FOR_X_DAYS: QuickModes.COOLING_FOR_X_DAYS,
}

def _ha_mode(self):
return ZoneClimateSenso._HA_MODE_TO_SENSO

def _multimatic_mode(self):
return ZoneClimateSenso._SENSO_TO_HA

def _ha_preset(self):
return ZoneClimateSenso._HA_PRESET_TO_SENSO
17 changes: 11 additions & 6 deletions custom_components/multimatic/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Config flow for multimatic integration."""
import logging

from pymultimatic.api import ApiError
from pymultimatic.api import ApiError, defaults
from pymultimatic.systemmanager import SystemManager
import voluptuous as vol

Expand All @@ -13,7 +13,7 @@
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv

from .const import CONF_SERIAL_NUMBER, DEFAULT_SCAN_INTERVAL, DOMAIN
from .const import CONF_SERIAL_NUMBER, DEFAULT_SCAN_INTERVAL, DOMAIN, CONF_APPLICATION

_LOGGER = logging.getLogger(__name__)

Expand All @@ -22,6 +22,7 @@
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_SERIAL_NUMBER): str,
vol.Required(CONF_APPLICATION, default="MULTIMATIC"): vol.In(["MULTIMATIC", "SENSO"]),
}
)

Expand All @@ -32,16 +33,20 @@ async def validate_input(hass: core.HomeAssistant, data):
Data has the keys from DATA_SCHEMA with values provided by the user.
"""

await validate_authentication(hass, data[CONF_USERNAME], data[CONF_PASSWORD])
await validate_authentication(hass, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_APPLICATION])

return {"title": "Multimatic"}


async def validate_authentication(hass, username, password):
async def validate_authentication(hass, username, password, application):
"""Ensure provided credentials are working."""
try:
systemApplication = defaults.SENSO if application == "SENSO" else defaults.MULTIMATIC
if not await SystemManager(
username, password, async_create_clientsession(hass)
user=username,
password=password,
session=async_create_clientsession(hass),
application=systemApplication,
).login(True):
raise InvalidAuth
except ApiError as err:
Expand All @@ -57,7 +62,7 @@ async def validate_authentication(hass, username, password):
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for multimatic."""

VERSION = 1
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

@staticmethod
Expand Down
Loading

0 comments on commit decf43c

Please sign in to comment.