Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ integration in Home Assistant.
* Control ventilation speed
* Control ventilation mode (auto / manual)
* Control ComfoCool mode (auto / off)
* Control boost mode (10 – 60 minutes, or off)
* Show various sensors

This integration supports the following additional features over the existing integration:
Expand All @@ -23,6 +24,7 @@ This integration supports the following additional features over the existing in
* Support to clear alarms
* Ignores invalid sensor values at the beginning of a session (Workaround for bridge firmware bug)
* Throttles high frequency sensor updates (airflow & fan duty) to once every 10 seconds
* Entities go unavailable in Home Assistant when the bridge connection is lost, and recover automatically on reconnect

**Note: Not all sensors are enabled by default. You can enable them on the integration page.**

Expand Down
14 changes: 6 additions & 8 deletions custom_components/comfoconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
_LOGGER = logging.getLogger(__name__)

SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = "comfoconnect_update_{}_{}"
SIGNAL_COMFOCONNECT_AVAILABLE = "comfoconnect_available_{}"

KEEP_ALIVE_INTERVAL = timedelta(seconds=30)

Expand Down Expand Up @@ -137,24 +138,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

@callback
async def send_keepalive(now) -> None:
"""Send keepalive to the bridge."""
_LOGGER.debug("Sending keepalive...")
try:
# Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for
await bridge.cmd_time_request()

# TODO: Mark sensors as available
dispatcher_send(hass, SIGNAL_COMFOCONNECT_AVAILABLE.format(bridge.uuid), True)

except (AioComfoConnectNotConnected, AioComfoConnectTimeout):
# Reconnect when connection has been dropped
try:
await bridge.connect(entry.data[CONF_LOCAL_UUID])
dispatcher_send(hass, SIGNAL_COMFOCONNECT_AVAILABLE.format(bridge.uuid), True)
except AioComfoConnectTimeout:
_LOGGER.debug("Connection timed out. Retrying later...")

# TODO: Mark all sensors as unavailable
dispatcher_send(hass, SIGNAL_COMFOCONNECT_AVAILABLE.format(bridge.uuid), False)

entry.async_on_unload(async_track_time_interval(hass, send_keepalive, KEEP_ALIVE_INTERVAL))

Expand Down Expand Up @@ -186,9 +185,8 @@ def __init__(self, hass: HomeAssistant, host: str, uuid: str):
super().__init__(
host,
uuid,
hass.loop,
self.sensor_callback,
self.alarm_callback,
sensor_callback=self.sensor_callback,
alarm_callback=self.alarm_callback,
)
self.hass = hass

Expand Down
15 changes: 13 additions & 2 deletions custom_components/comfoconnect/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge
from . import DOMAIN, SIGNAL_COMFOCONNECT_AVAILABLE, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -103,7 +103,6 @@ def __init__(
"""Initialize the ComfoConnect sensor."""
self._ccb = ccb
self.entity_description = description
self._attr_name = f"{description.name}"
self._attr_unique_id = f"{self._ccb.uuid}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._ccb.uuid)},
Expand All @@ -116,6 +115,13 @@ async def async_added_to_hass(self) -> None:
self.entity_description.name,
self.entity_description.key,
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_COMFOCONNECT_AVAILABLE.format(self._ccb.uuid),
self._handle_availability_update,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
Expand All @@ -125,6 +131,11 @@ async def async_added_to_hass(self) -> None:
)
await self._ccb.register_sensor(self.entity_description.ccb_sensor)

def _handle_availability_update(self, available: bool) -> None:
"""Handle availability updates."""
self._attr_available = available
self.schedule_update_ha_state()

def _handle_update(self, value):
"""Handle update callbacks."""
_LOGGER.debug(
Expand Down
1 change: 0 additions & 1 deletion custom_components/comfoconnect/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ def __init__(
"""Initialize the ComfoConnect sensor."""
self._ccb = ccb
self.entity_description = description
self._attr_name = f"{description.name}"
self._attr_unique_id = f"{self._ccb.uuid}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._ccb.uuid)},
Expand Down
87 changes: 58 additions & 29 deletions custom_components/comfoconnect/fan.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Fan for the ComfoConnect integration."""
"""Fan for the ComfoConnect integration with Manual → Auto fix."""

from __future__ import annotations

import asyncio
import logging
from typing import Any

Expand All @@ -22,7 +23,7 @@
percentage_to_ordered_list_item,
)

from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge
from . import DOMAIN, SIGNAL_COMFOCONNECT_AVAILABLE, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge

_LOGGER = logging.getLogger(__name__)

Expand All @@ -36,6 +37,11 @@
3: VentilationSpeed.HIGH,
}

MODE_MAPPING = {
-1: VentilationMode.AUTO,
1: VentilationMode.MANUAL,
}


async def async_setup_entry(
hass: HomeAssistant,
Expand All @@ -44,7 +50,6 @@ async def async_setup_entry(
) -> None:
"""Set up the ComfoConnect fan."""
ccb = hass.data[DOMAIN][config_entry.entry_id]

async_add_entities([ComfoConnectFan(ccb=ccb, config_entry=config_entry)], True)


Expand All @@ -54,7 +59,12 @@ class ComfoConnectFan(FanEntity):
_attr_enable_turn_on_off_backwards_compatibility = False
_attr_icon = "mdi:air-conditioner"
_attr_should_poll = False
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
_attr_preset_modes = list(PRESET_MODES)
_attr_speed_count = len(FAN_SPEEDS)
_attr_has_entity_name = True
Expand All @@ -65,13 +75,23 @@ def __init__(self, ccb: ComfoConnectBridge, config_entry: ConfigEntry) -> None:
self._ccb = ccb
self._attr_unique_id = self._ccb.uuid
self._attr_preset_mode = None
self._attr_percentage = 0
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._ccb.uuid)},
manufacturer="ComfoConnect",
model="ComfoAir Q",
name="ComfoAir Q Fan",
)

async def async_added_to_hass(self) -> None:
"""Register for sensor updates."""
_LOGGER.debug("Registering for fan speed")
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_COMFOCONNECT_AVAILABLE.format(self._ccb.uuid),
self._handle_availability_update,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
Expand All @@ -81,7 +101,6 @@ async def async_added_to_hass(self) -> None:
)
await self._ccb.register_sensor(SENSORS.get(SENSOR_FAN_SPEED_MODE))

_LOGGER.debug("Registering for operating mode")
self.async_on_remove(
async_dispatcher_connect(
self.hass,
Expand All @@ -91,66 +110,76 @@ async def async_added_to_hass(self) -> None:
)
await self._ccb.register_sensor(SENSORS.get(SENSOR_OPERATING_MODE))

def _handle_availability_update(self, available: bool) -> None:
"""Handle availability updates."""
self._attr_available = available
self.schedule_update_ha_state()

def _handle_speed_update(self, value: int) -> None:
"""Handle update callbacks."""
_LOGGER.debug("Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value)
if value == 0:
speed = FAN_SPEED_MAPPING.get(value, VentilationSpeed.LOW)
if speed == VentilationSpeed.AWAY:
self._attr_percentage = 0
else:
self._attr_percentage = ordered_list_item_to_percentage(FAN_SPEEDS, FAN_SPEED_MAPPING[value])
self._attr_percentage = ordered_list_item_to_percentage(FAN_SPEEDS, speed)

self.schedule_update_ha_state()

def _handle_mode_update(self, value: int) -> None:
"""Handle update callbacks."""
_LOGGER.debug(
"Handle update for operating mode (%d): %s",
SENSOR_OPERATING_MODE,
value,
)
self._attr_preset_mode = VentilationMode.AUTO if value == -1 else VentilationMode.MANUAL
self._attr_preset_mode = MODE_MAPPING.get(value, VentilationMode.MANUAL)
self.schedule_update_ha_state()

@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
return self.percentage > 0
def is_on(self) -> bool:
"""Return true if the fan is on."""
return self._attr_percentage > 0

async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if preset_mode:
await self.async_set_preset_mode(preset_mode)
return
"""Turn on the fan, ensuring it correctly goes to AUTO mode."""
if not self.is_on:
if percentage is None:
percentage = ordered_list_item_to_percentage(FAN_SPEEDS, VentilationSpeed.LOW)
await self.async_set_percentage(percentage)

if percentage is None:
await self.async_set_percentage(1) # Set fan speed to low
# Two-step switch forces the unit into AUTO: Manual → Auto
await self.async_set_preset_mode(VentilationMode.MANUAL)
await asyncio.sleep(0.5)
await self.async_set_preset_mode(VentilationMode.AUTO)
else:
await self.async_set_percentage(percentage)
if preset_mode:
await self.async_set_preset_mode(preset_mode)
if percentage is not None:
await self.async_set_percentage(percentage)

async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan (to away)."""
"""Turn off the fan (set to away)."""
await self.async_set_percentage(0)

async def async_set_percentage(self, percentage: int) -> None:
"""Set fan speed percentage."""
_LOGGER.debug("Changing fan speed percentage to %s", percentage)
percentage = max(0, min(percentage, 100))

if percentage == 0:
speed = VentilationSpeed.AWAY
else:
speed = percentage_to_ordered_list_item(FAN_SPEEDS, percentage)

await self._ccb.set_speed(speed)
self._attr_percentage = percentage
self.schedule_update_ha_state()

async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if preset_mode not in self.preset_modes:
raise ValueError(f"Invalid preset mode: {preset_mode}")
_LOGGER.warning("Invalid preset mode: %s", preset_mode)
return

_LOGGER.debug("Changing preset mode to %s", preset_mode)
await self._ccb.set_mode(preset_mode)
self._attr_preset_mode = preset_mode
self.schedule_update_ha_state()
Loading