Vallox ValloPlus 350MV partial success -> Work in progress on vallox component

Fix is upstream. Hope that resolves it for all.

https://github.com/home-assistant/home-assistant/commit/738d00fb05515265865a7e0b984c9ecaf7b2f82c

Hi,
I own a VALLOX 350 MV and I generally started to build up my little ā€œSmart Homeā€ with Home Assistant. I used the vallox Integration and I am thankfully able to see all Setting, Change Profile and set the profile fan Speed. I tried to understand your Code and how it interacts with the API, but having only some fundamentals in programming and not really started to get into phyton yet, I was not able to add a interaction to set the target air temperature for a profile.

So I would really appriciate, if somebody could add the entities for Setting AIR TARGET TEMP and also the Duration for the BOOST profile. Or give me some ā€œgeneralā€ hints or Maybe support to add more Input/sensor data.

Thanks.

Ok, meanwhile I managed it myself. After getting into the file again I got it. I am now able to Change the target temperatures for each profile and get some more sensor Information out of it.

this is what my UI Looks like at the Moment:

Hi Thorsten,

cool, was also on my todo-list to try to figure it out. Would you mind sharing an example code change?

Apologies for my curiosity: You are really running the vent of the 350MV with 60%? I do this only on Boost-Mode (Party). Otherwise if it is > 35% and silent in a room I hear it (and I cannot stand it), despite having installed already 3 mufflers in the supplying air.

Hi,

I am glad that some tries to work on this topic, too. This is what I did so far:

I am using the custom vertical stack-in card in combination with conditional cards. So once you select a Profile it changes to to right sliders for settings. So to answer your question here are my configuration-files:

UI-LOVELACE.YAML

- type: custom:vertical-stack-in-card
      title: VALLOX 350 MV
      cards:
      - type: entities
        entities:
        - fan.vallox
        
      - type: glance
        entities:
          - sensor.vallox_outdoor_air
          - sensor.vallox_supply_air
          - sensor.vallox_extract_air
          - sensor.vallox_exhaust_air
          - sensor.vallox_humidity
        
      - type: entities
        entities:
        - sensor.vallox_cell_state
        - sensor.aktuelles_profil
        - sensor.vallox_remaining_time_for_filter
        - input_select.ventilation_profile
        
      - type: conditional
        conditions:
          - entity: sensor.aktuelles_profil
            state: Anwesend
        card:
          type: entities
          entities:
          - sensor.vallox_fan_speed
          - sensor.vallox_air_temp_target_home
          - input_number.slider1
          - input_number.slider2
      - type: conditional
        conditions:
          - entity: sensor.aktuelles_profil
            state: Abwesend
        card:
          type: entities
          entities:
          - sensor.vallox_fan_speed
          - sensor.vallox_air_temp_target_away
          - input_number.slider5
          - input_number.slider3
          
      - type: conditional
        conditions:
          - entity: sensor.aktuelles_profil
            state: StosslĆ¼ften
        card:
          type: entities
          entities:
          - sensor.vallox_fan_speed
          - sensor.vallox_air_temp_target_boost
          - sensor.vallox_boost_timer
          - input_number.slider6
          - input_number.slider4
          - input_number.slider7

init.py from custom_components/vallox

"""Support for Vallox ventilation units."""

from datetime import timedelta
import ipaddress
import logging

from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox
from vallox_websocket_api.constants import vlxDevConstants
from vallox_websocket_api.exceptions import ValloxApiException
import voluptuous as vol

from homeassistant.const import CONF_HOST, CONF_NAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval

_LOGGER = logging.getLogger(__name__)

DOMAIN = "vallox"
DEFAULT_NAME = "Vallox"
SIGNAL_VALLOX_STATE_UPDATE = "vallox_state_update"
SCAN_INTERVAL = timedelta(seconds=60)

# Various metric keys that are reused between profiles.
METRIC_KEY_MODE = "A_CYC_MODE"
METRIC_KEY_PROFILE_FAN_SPEED_HOME = "A_CYC_HOME_SPEED_SETTING"
METRIC_KEY_PROFILE_FAN_SPEED_AWAY = "A_CYC_AWAY_SPEED_SETTING"
METRIC_KEY_PROFILE_FAN_SPEED_BOOST = "A_CYC_BOOST_SPEED_SETTING"
METRIC_KEY_PROFILE_FAN_TEMP_HOME = "A_CYC_HOME_AIR_TEMP_TARGET"
METRIC_KEY_PROFILE_FAN_TEMP_AWAY = "A_CYC_AWAY_AIR_TEMP_TARGET"
METRIC_KEY_PROFILE_FAN_TEMP_BOOST = "A_CYC_BOOST_AIR_TEMP_TARGET"
METRIC_KEY_PROFILE_FAN_TIME_BOOST = "A_CYC_BOOST_TIME"

CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema(
            {
                vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
                vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
            }
        )
    },
    extra=vol.ALLOW_EXTRA,
)

# pylint: disable=no-member
PROFILE_TO_STR_SETTABLE = {
    VALLOX_PROFILE.HOME: "Anwesend",
    VALLOX_PROFILE.AWAY: "Abwesend",
    VALLOX_PROFILE.BOOST: "StosslĆ¼ften",
    # VALLOX_PROFILE.FIREPLACE: "Fireplace",
}

STR_TO_PROFILE = {v: k for (k, v) in PROFILE_TO_STR_SETTABLE.items()}

# pylint: disable=no-member
PROFILE_TO_STR_REPORTABLE = {
    **{VALLOX_PROFILE.NONE: "None", VALLOX_PROFILE.EXTRA: "Extra"},
    **PROFILE_TO_STR_SETTABLE,
}

ATTR_PROFILE = "profile"
ATTR_PROFILE_FAN_SPEED = "fan_speed"
ATTR_PROFILE_FAN_TEMP = "fan_temp"
ATTR_PROFILE_FAN_TIME = "fan_time"

SERVICE_SCHEMA_SET_PROFILE = vol.Schema(
    {vol.Required(ATTR_PROFILE): vol.All(cv.string, vol.In(STR_TO_PROFILE))}
)

SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema(
    {
        vol.Required(ATTR_PROFILE_FAN_SPEED): vol.All(
            vol.Coerce(int), vol.Clamp(min=0, max=100)
        )
    }
)

SERVICE_SCHEMA_SET_PROFILE_FAN_TEMP = vol.Schema(
    {
        vol.Required(ATTR_PROFILE_FAN_TEMP): vol.All(
            vol.Coerce(float), vol.Clamp(min=5, max=25)
        )
    }
)

SERVICE_SCHEMA_SET_PROFILE_FAN_TIME = vol.Schema(
    {
        vol.Required(ATTR_PROFILE_FAN_TIME): vol.All(
            vol.Coerce(int), vol.Clamp(min=0, max=120)
        )
    }
)

SERVICE_SET_PROFILE = "set_profile"
SERVICE_SET_PROFILE_FAN_SPEED_HOME = "set_profile_fan_speed_home"
SERVICE_SET_PROFILE_FAN_SPEED_AWAY = "set_profile_fan_speed_away"
SERVICE_SET_PROFILE_FAN_SPEED_BOOST = "set_profile_fan_speed_boost"
SERVICE_SET_PROFILE_FAN_TEMP_HOME = "set_profile_fan_temp_home"
SERVICE_SET_PROFILE_FAN_TEMP_AWAY = "set_profile_fan_temp_away"
SERVICE_SET_PROFILE_FAN_TEMP_BOOST = "set_profile_fan_temp_boost"
SERVICE_SET_PROFILE_FAN_TIME_BOOST = "set_profile_fan_time_boost"

SERVICE_TO_METHOD = {
    SERVICE_SET_PROFILE: {
        "method": "async_set_profile",
        "schema": SERVICE_SCHEMA_SET_PROFILE,
    },
    SERVICE_SET_PROFILE_FAN_SPEED_HOME: {
        "method": "async_set_profile_fan_speed_home",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED,
    },
    SERVICE_SET_PROFILE_FAN_SPEED_AWAY: {
        "method": "async_set_profile_fan_speed_away",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED,
    },
    SERVICE_SET_PROFILE_FAN_SPEED_BOOST: {
        "method": "async_set_profile_fan_speed_boost",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED,
    },
    SERVICE_SET_PROFILE_FAN_TEMP_HOME: {
        "method": "async_set_profile_fan_temp_home",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_TEMP,
    },
    SERVICE_SET_PROFILE_FAN_TEMP_AWAY: {
        "method": "async_set_profile_fan_temp_away",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_TEMP,
    },
    SERVICE_SET_PROFILE_FAN_TEMP_BOOST: {
        "method": "async_set_profile_fan_temp_boost",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_TEMP,
    },
    SERVICE_SET_PROFILE_FAN_TIME_BOOST: {
        "method": "async_set_profile_fan_time_boost",
        "schema": SERVICE_SCHEMA_SET_PROFILE_FAN_TIME,
    },
}

DEFAULT_FAN_SPEED_HOME = 50
DEFAULT_FAN_SPEED_AWAY = 25
DEFAULT_FAN_SPEED_BOOST = 65
DEFAULT_FAN_TEMP_HOME = 15
DEFAULT_FAN_TEMP_AWAY = 15
DEFAULT_FAN_TEMP_BOOST = 15
DEFAULT_FAN_TIME_BOOST = 30

async def async_setup(hass, config):
    """Set up the client and boot the platforms."""
    conf = config[DOMAIN]
    host = conf.get(CONF_HOST)
    name = conf.get(CONF_NAME)

    client = Vallox(host)
    state_proxy = ValloxStateProxy(hass, client)
    service_handler = ValloxServiceHandler(client, state_proxy)

    hass.data[DOMAIN] = {"client": client, "state_proxy": state_proxy, "name": name}

    for vallox_service in SERVICE_TO_METHOD:
        schema = SERVICE_TO_METHOD[vallox_service]["schema"]
        hass.services.async_register(
            DOMAIN, vallox_service, service_handler.async_handle, schema=schema
        )

    # The vallox hardware expects quite strict timings for websocket
    # requests. Timings that machines with less processing power, like
    # Raspberries, cannot live up to during the busy start phase of Home
    # Asssistant. Hence, async_add_entities() for fan and sensor in respective
    # code will be called with update_before_add=False to intentionally delay
    # the first request, increasing chance that it is issued only when the
    # machine is less busy again.
    hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config))
    hass.async_create_task(async_load_platform(hass, "fan", DOMAIN, {}, config))

    async_track_time_interval(hass, state_proxy.async_update, SCAN_INTERVAL)

    return True


class ValloxStateProxy:
    """Helper class to reduce websocket API calls."""

    def __init__(self, hass, client):
        """Initialize the proxy."""
        self._hass = hass
        self._client = client
        self._metric_cache = {}
        self._profile = None
        self._valid = False

    def fetch_metric(self, metric_key):
        """Return cached state value."""
        _LOGGER.debug("Fetching metric key: %s", metric_key)

        if not self._valid:
            raise OSError("Device state out of sync.")

        if metric_key not in vlxDevConstants.__dict__:
            raise KeyError(f"Unknown metric key: {metric_key}")

        return self._metric_cache[metric_key]

    def get_profile(self):
        """Return cached profile value."""
        _LOGGER.debug("Returning profile")

        if not self._valid:
            raise OSError("Device state out of sync.")

        return PROFILE_TO_STR_REPORTABLE[self._profile]

    async def async_update(self, event_time):
        """Fetch state update."""
        _LOGGER.debug("Updating Vallox state cache")

        try:
            self._metric_cache = await self._client.fetch_metrics()
            self._profile = await self._client.get_profile()
            self._valid = True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error during state cache update: %s", err)
            self._valid = False

        async_dispatcher_send(self._hass, SIGNAL_VALLOX_STATE_UPDATE)


class ValloxServiceHandler:
    """Services implementation."""

    def __init__(self, client, state_proxy):
        """Initialize the proxy."""
        self._client = client
        self._state_proxy = state_proxy

    async def async_set_profile(self, profile: str = "Home") -> bool:
        """Set the ventilation profile."""
        _LOGGER.debug("Setting ventilation profile to: %s", profile)

        try:
            await self._client.set_profile(STR_TO_PROFILE[profile])
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting ventilation profile: %s", err)
            return False

    async def async_set_profile_fan_speed_home(
        self, fan_speed: int = DEFAULT_FAN_SPEED_HOME
    ) -> bool:
        """Set the fan speed in percent for the Home profile."""
        _LOGGER.debug("Setting Home fan speed to: %d%%", fan_speed)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_SPEED_HOME: fan_speed}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting fan speed for Home profile: %s", err)
            return False

    async def async_set_profile_fan_speed_away(
        self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY
    ) -> bool:
        """Set the fan speed in percent for the Home profile."""
        _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_SPEED_AWAY: fan_speed}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting fan speed for Away profile: %s", err)
            return False

    async def async_set_profile_fan_speed_boost(
        self, fan_speed: int = DEFAULT_FAN_SPEED_BOOST
    ) -> bool:
        """Set the fan speed in percent for the Boost profile."""
        _LOGGER.debug("Setting Boost fan speed to: %d%%", fan_speed)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_SPEED_BOOST: fan_speed}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting fan speed for Boost profile: %s", err)
            return False
            
    async def async_set_profile_fan_temp_home(
        self, fan_temp: float = DEFAULT_FAN_TEMP_HOME
    ) -> bool:
        """Set the temperature target in degrees Celsius for the Home profile."""
        _LOGGER.debug("Setting Home temperature aim to: %d%%", fan_temp)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_TEMP_HOME: fan_temp}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting temperature aim for Home profile: %s", err)
            return False
    
    async def async_set_profile_fan_temp_away(
        self, fan_temp: float = DEFAULT_FAN_TEMP_AWAY
    ) -> bool:
        """Set the temperature target in degrees Celsius for the Away profile."""
        _LOGGER.debug("Setting Away temperature aim to: %d%%", fan_temp)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_TEMP_AWAY: fan_temp}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting temperature aim for Away profile: %s", err)
            return False
            
    async def async_set_profile_fan_temp_boost(
        self, fan_temp: float = DEFAULT_FAN_TEMP_BOOST
    ) -> bool:
        """Set the temperature target in degrees Celsius for the Boost profile."""
        _LOGGER.debug("Setting Boost temperature aim to: %d%%", fan_temp)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_TEMP_BOOST: fan_temp}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting temperature aim for Home profile: %s", err)
            return False
            
    async def async_set_profile_fan_time_boost(
        self, fan_time: int = DEFAULT_FAN_TIME_BOOST
    ) -> bool:
        """Set the time in minutes for the Boost profile."""
        _LOGGER.debug("Setting Boost time to: %d%%", fan_time)

        try:
            await self._client.set_values(
                {METRIC_KEY_PROFILE_FAN_TIME_BOOST: fan_time}
            )
            return True

        except (OSError, ValloxApiException) as err:
            _LOGGER.error("Error setting time for Boost profile: %s", err)
            return False
            
    async def async_handle(self, service):
        """Dispatch a service call."""
        method = SERVICE_TO_METHOD.get(service.service)
        params = service.data.copy()

        if not hasattr(self, method["method"]):
            _LOGGER.error("Service not implemented: %s", method["method"])
            return

        result = await getattr(self, method["method"])(**params)

        # Force state_proxy to refresh device state, so that updates are
        # propagated to platforms.
        if result:
            await self._state_proxy.async_update(None)

fan.py from custom_components/vallox

"""Support for the Vallox ventilation unit fan."""

import logging

from homeassistant.components.fan import FanEntity
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect

from . import (
    DOMAIN,
    METRIC_KEY_MODE,
    METRIC_KEY_PROFILE_FAN_SPEED_AWAY,
    METRIC_KEY_PROFILE_FAN_SPEED_BOOST,
    METRIC_KEY_PROFILE_FAN_SPEED_HOME,
    METRIC_KEY_PROFILE_FAN_TEMP_HOME,
    METRIC_KEY_PROFILE_FAN_TEMP_AWAY,
    METRIC_KEY_PROFILE_FAN_TEMP_BOOST,
    METRIC_KEY_PROFILE_FAN_TIME_BOOST,
    SIGNAL_VALLOX_STATE_UPDATE,
)

_LOGGER = logging.getLogger(__name__)

# Device attributes
ATTR_PROFILE_FAN_SPEED_HOME = {
    "description": "fan_speed_home",
    "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_HOME,
}
ATTR_PROFILE_FAN_SPEED_AWAY = {
    "description": "fan_speed_away",
    "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_AWAY,
}
ATTR_PROFILE_FAN_SPEED_BOOST = {
    "description": "fan_speed_boost",
    "metric_key": METRIC_KEY_PROFILE_FAN_SPEED_BOOST,
}
ATTR_PROFILE_FAN_TEMP_HOME = {
    "description": "fan_temp_home",
    "metric_key": METRIC_KEY_PROFILE_FAN_TEMP_HOME,
}
ATTR_PROFILE_FAN_TEMP_AWAY = {
    "description": "fan_temp_away",
    "metric_key": METRIC_KEY_PROFILE_FAN_TEMP_AWAY,
}
ATTR_PROFILE_FAN_TEMP_BOOST = {
    "description": "fan_temp_boost",
    "metric_key": METRIC_KEY_PROFILE_FAN_TEMP_BOOST,
}
ATTR_PROFILE_FAN_TIME_BOOST = {
    "description": "fan_time_boost",
    "metric_key": METRIC_KEY_PROFILE_FAN_TIME_BOOST,
}

async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    """Set up the fan device."""
    if discovery_info is None:
        return

    client = hass.data[DOMAIN]["client"]

    client.set_settable_address(METRIC_KEY_MODE, int)

    device = ValloxFan(
        hass.data[DOMAIN]["name"], client, hass.data[DOMAIN]["state_proxy"]
    )

    async_add_entities([device], update_before_add=False)


class ValloxFan(FanEntity):
    """Representation of the fan."""

    def __init__(self, name, client, state_proxy):
        """Initialize the fan."""
        self._name = name
        self._client = client
        self._state_proxy = state_proxy
        self._available = False
        self._state = None
        self._fan_speed_home = None
        self._fan_speed_away = None
        self._fan_speed_boost = None
        self._fan_temp_home = None
        self._fan_temp_away = None
        self._fan_temp_boost = None
        self._fan_time_boost = None

    @property
    def should_poll(self):
        """Do not poll the device."""
        return False

    @property
    def name(self):
        """Return the name of the device."""
        return self._name

    @property
    def available(self):
        """Return if state is known."""
        return self._available

    @property
    def is_on(self):
        """Return if device is on."""
        return self._state

    @property
    def device_state_attributes(self):
        """Return device specific state attributes."""
        return {
            ATTR_PROFILE_FAN_SPEED_HOME["description"]: self._fan_speed_home,
            ATTR_PROFILE_FAN_SPEED_AWAY["description"]: self._fan_speed_away,
            ATTR_PROFILE_FAN_SPEED_BOOST["description"]: self._fan_speed_boost,
            ATTR_PROFILE_FAN_TEMP_HOME["description"]: self._fan_temp_home,
            ATTR_PROFILE_FAN_TEMP_AWAY["description"]: self._fan_temp_away,
            ATTR_PROFILE_FAN_TEMP_BOOST["description"]: self._fan_temp_boost,
            ATTR_PROFILE_FAN_TIME_BOOST["description"]: self._fan_time_boost,
        }

    async def async_added_to_hass(self):
        """Call to update."""
        async_dispatcher_connect(
            self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback
        )

    @callback
    def _update_callback(self):
        """Call update method."""
        self.async_schedule_update_ha_state(True)

    async def async_update(self):
        """Fetch state from the device."""
        try:
            # Fetch if the whole device is in regular operation state.
            mode = self._state_proxy.fetch_metric(METRIC_KEY_MODE)
            if mode == 0:
                self._state = True
            else:
                self._state = False

            # Fetch the profile fan speeds.
            self._fan_speed_home = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_SPEED_HOME["metric_key"]
                )
            )
            self._fan_speed_away = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_SPEED_AWAY["metric_key"]
                )
            )
            self._fan_speed_boost = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_SPEED_BOOST["metric_key"]
                )
            )
            self._fan_temp_home = float(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_TEMP_HOME["metric_key"]
                )
            )
            self._fan_temp_away = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_TEMP_AWAY["metric_key"]
                )
            )
            self._fan_temp_boost = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_TEMP_BOOST["metric_key"]
                )
            )
            self._fan_time_boost = int(
                self._state_proxy.fetch_metric(
                    ATTR_PROFILE_FAN_TIME_BOOST["metric_key"]
                )
            )
            
            self._available = True

        except (OSError, KeyError) as err:
            self._available = False
            _LOGGER.error("Error updating fan: %s", err)

    async def async_turn_on(self, speed: str = None, **kwargs) -> None:
        """Turn the device on."""
        _LOGGER.debug("Turn on: %s", speed)

        # Only the case speed == None equals the GUI toggle switch being
        # activated.
        if speed is not None:
            return

        if self._state is False:
            try:
                await self._client.set_values({METRIC_KEY_MODE: 0})

                # This state change affects other entities like sensors. Force
                # an immediate update that can be observed by all parties
                # involved.
                await self._state_proxy.async_update(None)

            except OSError as err:
                self._available = False
                _LOGGER.error("Error turning on: %s", err)
        else:
            _LOGGER.error("Already on")

    async def async_turn_off(self, **kwargs) -> None:
        """Turn the device off."""
        if self._state is True:
            try:
                await self._client.set_values({METRIC_KEY_MODE: 5})

                # Same as for turn_on method.
                await self._state_proxy.async_update(None)

            except OSError as err:
                self._available = False
                _LOGGER.error("Error turning off: %s", err)
        else:
            _LOGGER.error("Already off")

sensor.py from custom_components/vallox

"""Support for Vallox ventilation unit sensors."""

from datetime import datetime, timedelta
import logging

from homeassistant.const import (
    DEVICE_CLASS_HUMIDITY,
    DEVICE_CLASS_TEMPERATURE,
    DEVICE_CLASS_TIMESTAMP,
    TEMP_CELSIUS,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity

from . import DOMAIN, METRIC_KEY_MODE, SIGNAL_VALLOX_STATE_UPDATE

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    """Set up the sensors."""
    if discovery_info is None:
        return

    name = hass.data[DOMAIN]["name"]
    state_proxy = hass.data[DOMAIN]["state_proxy"]

    sensors = [
        ValloxProfileSensor(
#            name=f"{name} Current Profile",
            name=f"Aktuelles Profil",
            state_proxy=state_proxy,
            device_class=None,
            unit_of_measurement=None,
            icon="mdi:gauge",
        ),
        ValloxFanSpeedSensor(
            name=f"{name} Fan Speed",
            state_proxy=state_proxy,
            metric_key="A_CYC_FAN_SPEED",
            device_class=None,
            unit_of_measurement="%",
            icon="mdi:fan",
        ),
        ValloxSensor(
            name=f"{name} Extract Air",
            state_proxy=state_proxy,
            metric_key="A_CYC_TEMP_EXTRACT_AIR",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Exhaust Air",
            state_proxy=state_proxy,
            metric_key="A_CYC_TEMP_EXHAUST_AIR",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Outdoor Air",
            state_proxy=state_proxy,
            metric_key="A_CYC_TEMP_OUTDOOR_AIR",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Supply Air",
            state_proxy=state_proxy,
            metric_key="A_CYC_TEMP_SUPPLY_AIR",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Humidity",
            state_proxy=state_proxy,
            metric_key="A_CYC_RH_VALUE",
            device_class=DEVICE_CLASS_HUMIDITY,
            unit_of_measurement="%",
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Air Temp Target Home",
            state_proxy=state_proxy,
            metric_key="A_CYC_HOME_AIR_TEMP_TARGET",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Air Temp Target Away",
            state_proxy=state_proxy,
            metric_key="A_CYC_AWAY_AIR_TEMP_TARGET",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Air Temp Target Boost",
            state_proxy=state_proxy,
            metric_key="A_CYC_BOOST_AIR_TEMP_TARGET",
            device_class=DEVICE_CLASS_TEMPERATURE,
            unit_of_measurement=TEMP_CELSIUS,
            icon=None,
        ),
        ValloxFilterRemainingSensor(
            name=f"{name} Remaining Time For Filter",
            state_proxy=state_proxy,
            metric_key="A_CYC_REMAINING_TIME_FOR_FILTER",
            device_class=DEVICE_CLASS_TIMESTAMP,
            unit_of_measurement=None,
            icon="mdi:filter",
        ),
        ValloxSensor(
            name=f"{name} Boost Timer",
            state_proxy=state_proxy,
            metric_key="A_CYC_BOOST_TIME",
            device_class=None,
            unit_of_measurement="Minuten",
            icon=None,
        ),
        ValloxSensor(
            name=f"{name} Cell State",
            state_proxy=state_proxy,
            metric_key="A_CYC_CELL_STATE",
            device_class=None,
            unit_of_measurement=None,
            icon=None,
        ),
    ]

    async_add_entities(sensors, update_before_add=False)


class ValloxSensor(Entity):
    """Representation of a Vallox sensor."""

    def __init__(
        self, name, state_proxy, metric_key, device_class, unit_of_measurement, icon
    ) -> None:
        """Initialize the Vallox sensor."""
        self._name = name
        self._state_proxy = state_proxy
        self._metric_key = metric_key
        self._device_class = device_class
        self._unit_of_measurement = unit_of_measurement
        self._icon = icon
        self._available = None
        self._state = None

    @property
    def should_poll(self):
        """Do not poll the device."""
        return False

    @property
    def name(self):
        """Return the name."""
        return self._name

    @property
    def unit_of_measurement(self):
        """Return the unit of measurement."""
        return self._unit_of_measurement

    @property
    def device_class(self):
        """Return the device class."""
        return self._device_class

    @property
    def icon(self):
        """Return the icon."""
        return self._icon

    @property
    def available(self):
        """Return true when state is known."""
        return self._available

    @property
    def state(self):
        """Return the state."""
        return self._state

    async def async_added_to_hass(self):
        """Call to update."""
        async_dispatcher_connect(
            self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback
        )

    @callback
    def _update_callback(self):
        """Call update method."""
        self.async_schedule_update_ha_state(True)

    async def async_update(self):
        """Fetch state from the ventilation unit."""
        try:
            self._state = self._state_proxy.fetch_metric(self._metric_key)
            self._available = True

        except (OSError, KeyError) as err:
            self._available = False
            _LOGGER.error("Error updating sensor: %s", err)


# There seems to be a quirk with respect to the fan speed reporting. The device
# keeps on reporting the last valid fan speed from when the device was in
# regular operation mode, even if it left that state and has been shut off in
# the meantime.
#
# Therefore, first query the overall state of the device, and report zero
# percent fan speed in case it is not in regular operation mode.
class ValloxFanSpeedSensor(ValloxSensor):
    """Child class for fan speed reporting."""

    async def async_update(self):
        """Fetch state from the ventilation unit."""
        try:
            # If device is in regular operation, continue.
            if self._state_proxy.fetch_metric(METRIC_KEY_MODE) == 0:
                await super().async_update()
            else:
                # Report zero percent otherwise.
                self._state = 0
                self._available = True

        except (OSError, KeyError) as err:
            self._available = False
            _LOGGER.error("Error updating sensor: %s", err)


class ValloxProfileSensor(ValloxSensor):
    """Child class for profile reporting."""

    def __init__(
        self, name, state_proxy, device_class, unit_of_measurement, icon
    ) -> None:
        """Initialize the Vallox sensor."""
        super().__init__(
            name, state_proxy, None, device_class, unit_of_measurement, icon
        )

    async def async_update(self):
        """Fetch state from the ventilation unit."""
        try:
            self._state = self._state_proxy.get_profile()
            self._available = True

        except OSError as err:
            self._available = False
            _LOGGER.error("Error updating sensor: %s", err)


class ValloxFilterRemainingSensor(ValloxSensor):
    """Child class for filter remaining time reporting."""

    async def async_update(self):
        """Fetch state from the ventilation unit."""
        try:
            days_remaining = int(self._state_proxy.fetch_metric(self._metric_key))
            days_remaining_delta = timedelta(days=days_remaining)

            # Since only a delta of days is received from the device, fix the
            # time so the timestamp does not change with every update.
            now = datetime.utcnow().replace(hour=13, minute=0, second=0, microsecond=0)

            self._state = (now + days_remaining_delta).isoformat()
            self._available = True

        except (OSError, KeyError) as err:
            self._available = False
            _LOGGER.error("Error updating sensor: %s", err)

Now here are two issues where I would be glad to get some help solving them:

  1. I was able to get the Cell-State, but it returns only numbers from 0 to 3. How can I get the numbers replaced with a text string to show up in lovelace UI?

  2. I was able to get the time setting for boost mode. Also, I manged to set a new boost time. But I get the message, that the attribute is not settable (see log below), but the modbus setting for vallox states, it is Read/Write. Is there some modification in the API nessecary or does anybody know a workaround?

Sorry, forgot to answer your question about the Vallox Setting. Currently I am running it at 60%, thatĀ“s right. It actually depends on the size of house (the ammount of air) you are running it and the air-change-rate you want to reach. I have 5 air-blow-in and 3 air-extract points on it. So I need to have that high setting. But due to the fact, that the air is separated to 5 injetion tubes, is not that loud anymore. Hope that answers your question.

as able to get the Cell-State, but it returns only numbers from 0 to 3. How can I get the numbers replaced with a text string to show up in lovelace UI?

0=Heat recovery
1=Cooling
2=Bypass
3=Defrost

I was able to get the time setting for boost mode. Also, I manged to set a new boost time. But I get the message, that the attribute is not settable (see log below), but the modbus setting for vallox states, it is Read/Write.

It was for safety reasons before vallox released fist modbus doc. My lib does not allow to set all variables that are in that doc. I whitelist some of them. I will add BOOST_TIME to the list in the next version.

A_CYC_BOOST_TIMER is currently writable in my lib. A_CYC_BOOST_TIME is not.

BOOST_TIMER: Boost timer. Timer is enabled from 21766
BOOST_TIME: Boost timer load value

Which one you need?

All settings that report they are not settable in the lib can be easily made settable with
client.set_settable_address('A_CYC_BOOST_TIME', int)

Hi @Torsten-H,

are you planning to create a PR?
Would be great to see your enhancements in the official component.

Thanks for your work!

Just FYI, I created a Pull Request adding an automation that updates the vallox input_select when some external force changes the ventilation unit profile. Eg through its web interface or the mechanical switch. It can be found here and I hope it will be mergedā€¦

Slightly old topic, but did you ever get to solve the problem of replacing the numbers with a text?

Iā€™ve the same challenge with A_CYC_CELL_STATE and Iā€™ve also included A_CYC_MLV_STATE (state of the relay for cooling) in the sensor.py. As a work around, Iā€™ve created a template sensor, but it would be cleaner to have it directly in the code.

Are there any plans to make the A_CYC_CELL_STATE available to the integration? It would be very helpful to get the current status.

1 Like

Iā€™m intrested in this as well.

Iā€™m willing to try this myself as well. I was checking the Torsten-Hā€™s modifications, but there has probably been some changes to the component, so this needs little bit more research for me.

Any suggestion how to proceed? Copy core/vallox to custom_components/vallox (does it still work like that, or should I rename the custom_component copy? What about configuration.xml in that case?) and start adding sensors etc.?

Can you give code snippets for those and overall view how you customized this? Copying the current source to custom_components etc.?

BOOST_TIME would be needed for the above use case.

Btw., any plans for mqtt bridge for your library?

I use the library via MQTT myself, because I control ventilation in openhab.
I think it is better to update home assistant integration than to make a new MQTT interface.

I understand. However, is this mqtt-implementation for the library available somewhere?

I copied the whole Vallox integration code to custom_components/vallox.

Then I added the code in sensor.py. See the spot from the picture. I added couple of other sensors as well.


        ValloxSensor(
            name=f"{name} Cell State",
            state_proxy=state_proxy,
            metric_key="A_CYC_CELL_STATE",
            device_class=None,
            unit_of_measurement=None,
            icon=None,
        ),

Then in configuration.yaml I added below code to translate the numerical status into text.


  - platform: template
    sensors:    
     vallox_cell_state_desc:
      friendly_name: "Vallox Cell State"
      value_template: >-
         {% if is_state('sensor.vallox_cell_state', '0') %}
            Heat recovery
         {% elif is_state('sensor.vallox_cell_state', '1') %}
            Cooling
         {% elif is_state('sensor.vallox_cell_state', '2') %}
            Bypass
         {% elif is_state('sensor.vallox_cell_state', '3') %}
            Defrost
         {% else %}
            NA
         {% endif %}

Thanks! Just managed to do a little bit of the same, still fixing to do, you snips will help here enormously!

Your sensor-template is much more nicer than mine. :slight_smile:

Iā€™m currently looking for elegant way to pass integers for ā€œA_CYC_FIREPLACE_TIMERā€ and for ā€œA_CYC_BOOST_TIMERā€.

Idea over here is to A) run Fireplace mode with normal 15min timer when lighting up the fireplace and B) run Fireplace mode ā€œindefinitelyā€ (with value of 65535) while cooker hood is on, return to Home when cooker hood is turned off. I made it work even with normal profile settings, however I need watchdog on my script to turn Fireplace mode back on, if cooker hood is still on after 15 minutes and Iā€™d like to avoid that :slight_smile:

Was simplier than I thought.

I defined additional set_profile in the init.py which basically does following (as described vallox_websocket_api):

_LOGGER.debug(ā€œSetting Fireplace indefinetelyā€)

    try:
        await self._client.set_values({'A_CYC_FIREPLACE_TIMER': 65535})
        return True

    except (OSError, ValloxApiException) as err:
        _LOGGER.error("Error setting indefiniteFireplace profile: %s", err)
        return False