Skip to content

Commit

Permalink
Implement auto/manual suppoert for thermostats (climate entities)
Browse files Browse the repository at this point in the history
The messaging system had to be adjusted to support multiple properties per capability, that's why all the entity types were touched. The default state change event is now `"{LIVISI_STATE_CHANGE}_{CAPABILITY}_{PROPERTY}"`
  • Loading branch information
planbnet committed Feb 13, 2024
1 parent 7476d87 commit 25ea4a8
Show file tree
Hide file tree
Showing 11 changed files with 161 additions and 42 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ coverage.xml

# example file of my local config that should not be public
devices.json
messages.json

# Home Assistant configuration
config/*
Expand Down
4 changes: 3 additions & 1 deletion custom_components/livisi/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,12 @@ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()

property_name: str = self.entity_description.key

self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self.capability_id}",
f"{LIVISI_STATE_CHANGE}_{self.capability_id}_{property_name}",
self.update_states,
)
)
Expand Down
105 changes: 91 additions & 14 deletions custom_components/livisi/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@

from .const import (
DOMAIN,
HUMIDITY,
LIVISI_STATE_CHANGE,
LOGGER,
MAX_TEMPERATURE,
MIN_TEMPERATURE,
OPERATION_MODE,
POINT_TEMPERATURE,
SETPOINT_TEMPERATURE,
TEMPERATURE,
VRCC_DEVICE_TYPES,
)

Expand Down Expand Up @@ -65,7 +70,7 @@ def handle_coordinator_update() -> None:
class LivisiClimate(LivisiEntity, ClimateEntity):
"""Represents the Livisi Climate."""

_attr_hvac_modes = []
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT]
_attr_hvac_mode = HVACMode.HEAT
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
Expand All @@ -90,7 +95,18 @@ def __init__(
self._attr_max_temp = config.get("maxTemperature", MAX_TEMPERATURE)
self._attr_min_temp = config.get("minTemperature", MIN_TEMPERATURE)

self._thermostat_actuator_ids: [str] = [
id.strip()
for id in device.capability_config.get("RoomSetpoint", {})
.get("underlyingCapabilityIds", "")
.split(",")
]

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature. Overrides hass method."""
await self.async_set_livisi_temperature(kwargs.get(ATTR_TEMPERATURE))

async def async_set_livisi_temperature(self, target_temp) -> bool:
"""Set new target temperature."""
success = await self.aio_livisi.async_set_state(
self._target_temperature_capability,
Expand All @@ -99,31 +115,56 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
if self.coordinator.aiolivisi.controller.is_v2
else "pointTemperature"
),
value=kwargs.get(ATTR_TEMPERATURE),
value=target_temp,
)
if not success:
self.update_reachability(False)
raise HomeAssistantError(f"Failed to set temperature on {self._attr_name}")
self.update_reachability(True)
return success

async def async_set_mode(self, auto: bool) -> bool:
"""Set new manual/auto mode."""

# ignore if no thermostats are connected
if len(self._thermostat_actuator_ids) == 0:
return False

# setting one of the thermostats is enough, livisi will sync the state
thermostat_capability_id = self._thermostat_actuator_ids[0]

success = await self.aio_livisi.async_set_state(
thermostat_capability_id,
key="operationMode",
value=("Auto" if auto else "Manu"),
)
if not success:
self.update_reachability(False)
raise HomeAssistantError(f"Failed to set mode on {self._attr_name}")
self.update_reachability(True)

return success

async def async_added_to_hass(self) -> None:
"""Register callbacks."""

await super().async_added_to_hass()

target_temp_property = (
SETPOINT_TEMPERATURE
if self.coordinator.aiolivisi.controller.is_v2
else POINT_TEMPERATURE
)

target_temperature = await self.coordinator.aiolivisi.async_get_device_state(
self._target_temperature_capability,
(
"setpointTemperature"
if self.coordinator.aiolivisi.controller.is_v2
else "pointTemperature"
),
target_temp_property,
)
temperature = await self.coordinator.aiolivisi.async_get_device_state(
self._temperature_capability, "temperature"
self._temperature_capability, TEMPERATURE
)
humidity = await self.coordinator.aiolivisi.async_get_device_state(
self._humidity_capability, "humidity"
self._humidity_capability, HUMIDITY
)
if temperature is None:
self._attr_current_temperature = None
Expand All @@ -134,37 +175,63 @@ async def async_added_to_hass(self) -> None:
self._attr_current_humidity = humidity
self.update_reachability(True)

if len(self._thermostat_actuator_ids) > 0:
mode = await self.coordinator.aiolivisi.async_get_device_state(
self._thermostat_actuator_ids[0], OPERATION_MODE
)
self.update_mode(mode)

self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}",
f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}_{target_temp_property}",
self.update_target_temperature,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}",
f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}_{TEMPERATURE}",
self.update_temperature,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}",
f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}_{HUMIDITY}",
self.update_humidity,
)
)

def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Do nothing as LIVISI devices do not support changing the hvac mode."""
for thermostat_capability in self._thermostat_actuator_ids:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{thermostat_capability}_{OPERATION_MODE}",
self.update_mode,
)
)

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Find a matching thermostat and use it to set the hvac mode."""
if hvac_mode == HVACMode.OFF:
if await self.async_set_livisi_temperature(self._attr_min_temp):
self._attr_hvac_mode = HVACMode.OFF
elif hvac_mode == HVACMode.AUTO:
if await self.async_set_mode(auto=True):
self._attr_hvac_mode = HVACMode.AUTO
elif hvac_mode == HVACMode.HEAT:
if await self.async_set_mode(auto=False):
self._attr_hvac_mode = HVACMode.HEAT
self.async_write_ha_state()

@property
def hvac_action(self) -> HVACAction | None:
"""Calculate current hvac state based on target and current temperature."""
if (
self._attr_current_temperature is None
or self._attr_target_temperature is None
or self._attr_hvac_mode == HVACMode.OFF
):
return HVACAction.OFF
if self._attr_target_temperature > self._attr_current_temperature:
Expand All @@ -190,3 +257,13 @@ def update_humidity(self, humidity: int) -> None:
"""Update the humidity of the climate device."""
self._attr_current_humidity = humidity
self.async_write_ha_state()

@callback
def update_mode(self, val: any) -> None:
"""Update the current mode if devices switch from manual to automatic or vice versa."""
if val == "Auto":
self._attr_hvac_mode = HVACMode.AUTO
else:
self._attr_hvac_mode = HVACMode.HEAT
if self.hass is not None:
self.async_write_ha_state()
8 changes: 6 additions & 2 deletions custom_components/livisi/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Constants for the Livisi Smart Home integration."""

import logging
from typing import Final

Expand Down Expand Up @@ -64,7 +65,9 @@
DIM_LEVEL: Final = "dimLevel"
VALUE: Final = "value"
POINT_TEMPERATURE: Final = "pointTemperature"
SET_POINT_TEMPERATURE: Final = "setpointTemperature"
SETPOINT_TEMPERATURE: Final = "setpointTemperature"
OPERATION_MODE: Final = "operationMode"
ACTIVE_CHANNEL: Final = "activeChannel"

IS_OPEN: Final = "isOpen"
IS_SMOKE_ALARM: Final = "isSmokeAlarm"
Expand All @@ -76,7 +79,7 @@
ON_STATE,
VALUE,
POINT_TEMPERATURE,
SET_POINT_TEMPERATURE,
SETPOINT_TEMPERATURE,
TEMPERATURE,
HUMIDITY,
LUMINANCE,
Expand All @@ -85,6 +88,7 @@
POWER_CONSUMPTION,
SHUTTER_LEVEL,
DIM_LEVEL,
OPERATION_MODE,
]

EVENT_BUTTON_PRESSED = "button_pressed"
Expand Down
15 changes: 12 additions & 3 deletions custom_components/livisi/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,16 @@ async def _async_update_data(self) -> list[LivisiDevice]:
except ClientConnectorError as exc:
raise UpdateFailed("Failed to get livisi devices from controller") from exc

def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None:
def _async_dispatcher_send(
self, event: str, source: str, data: Any, property_name=None
) -> None:
if data is not None:
async_dispatcher_send(self.hass, f"{event}_{source}", data)
if property_name is None:
async_dispatcher_send(self.hass, f"{event}_{source}", data)
else:
async_dispatcher_send(
self.hass, f"{event}_{source}_{property_name}", data
)

def publish_state(
self, event_data: LivisiWebsocketEvent, property_name: str
Expand All @@ -96,7 +103,9 @@ def publish_state(
data = event_data.properties.get(property_name, None)
if data is None:
return False
self._async_dispatcher_send(LIVISI_STATE_CHANGE, event_data.source, data)
self._async_dispatcher_send(
LIVISI_STATE_CHANGE, event_data.source, data, property_name
)
return True

async def async_get_devices(self) -> list[LivisiDevice]:
Expand Down
3 changes: 2 additions & 1 deletion custom_components/livisi/cover.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Code to handle a Livisi shutters."""

from __future__ import annotations

from typing import Any
Expand Down Expand Up @@ -161,7 +162,7 @@ async def async_added_to_hass(self) -> None:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self.capability_id}",
f"{LIVISI_STATE_CHANGE}_{self.capability_id}_{SHUTTER_LEVEL}",
self.update_states,
)
)
Expand Down
14 changes: 8 additions & 6 deletions custom_components/livisi/light.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Code to handle a Livisi switches."""

from __future__ import annotations

from typing import Any
Expand All @@ -18,6 +19,7 @@
DOMAIN,
LIVISI_STATE_CHANGE,
LOGGER,
ON_STATE,
SWITCH_DEVICE_TYPES,
DIMMING_DEVICE_TYPES,
)
Expand Down Expand Up @@ -90,7 +92,7 @@ async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()

response = await self.coordinator.aiolivisi.async_get_device_state(
self.capability_id, "onState"
self.capability_id, ON_STATE
)
if response is None:
self.update_reachability(False)
Expand All @@ -101,15 +103,15 @@ async def async_added_to_hass(self) -> None:
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self.capability_id}",
f"{LIVISI_STATE_CHANGE}_{self.capability_id}_{ON_STATE}",
self.update_states,
)
)

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
success = await self.aio_livisi.async_set_state(
self.capability_id, key="onState", value=True
self.capability_id, key=ON_STATE, value=True
)

if not success:
Expand All @@ -123,7 +125,7 @@ async def async_turn_on(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
success = await self.aio_livisi.async_set_state(
self.capability_id, key="onState", value=False
self.capability_id, key=ON_STATE, value=False
)
if not success:
self.update_reachability(False)
Expand Down Expand Up @@ -162,15 +164,15 @@ async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()

response = await self.coordinator.aiolivisi.async_get_device_state(
self.capability_id, "dimLevel"
self.capability_id, DIM_LEVEL
)
if response is not None:
self.update_brightness(response)

self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self.capability_id}",
f"{LIVISI_STATE_CHANGE}_{self.capability_id}_{DIM_LEVEL}",
self.update_brightness,
)
)
Expand Down
1 change: 1 addition & 0 deletions custom_components/livisi/livisi_websocket.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Code for communication with the Livisi application websocket."""

from collections.abc import Callable
import urllib.parse

Expand Down
5 changes: 4 additions & 1 deletion custom_components/livisi/sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Code to handle a Livisi Sensor."""

from __future__ import annotations
from decimal import Decimal

Expand Down Expand Up @@ -141,10 +142,12 @@ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()

property_name: str = self.entity_description.key

self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{LIVISI_STATE_CHANGE}_{self.capability_id}",
f"{LIVISI_STATE_CHANGE}_{self.capability_id}_{property_name}",
self.update_states,
)
)
Expand Down
Loading

0 comments on commit 25ea4a8

Please sign in to comment.