yangqian / hass-cozylife

third party cozylife integration
MIT License
66 stars 14 forks source link

2-gang switch fixed #31

Open BonefaceJohnson opened 5 months ago

BonefaceJohnson commented 5 months ago

UPDATED, CHECK POST BELOW!!

took me a whole day and some help of AI. But I finally made it to control a two gang switch with two seperate entities!

Split your configuration.yaml in two parts: "switches" for sockets etc. "switches2" for 2-gang wall switches

switch:
- platform: cozylife
  switches:
  - ip: 192.168.178.10
    did: 51039349083a8d53f900 #Sekretär
    pid: esEM1c
    dmn: Metering Socket
    dpid: [1, 2, 3, 18, 19, 20, 21, 26, 27, 28, 29, 30, 31, 32]
  switches2:
  - ip: 192.168.178.12
    did: 4324820980646f4c6700 #Wohnzimmer
    pid: e5aHVS
    dmn: Smart switch
    dpid: [1, 2, 3, 4, 5]

_replace your custom_components/cozylife/switch.py with this:


"""Platform for sensor integration."""
from __future__ import annotations
import logging
from .tcp_client import tcp_client
from datetime import timedelta
import asyncio

from homeassistant.components.sensor import SensorEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import TEMP_CELSIUS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.event import async_track_time_interval

from typing import Any, Final, Literal, TypedDict, final
from .const import (
    DOMAIN,
    SWITCH_TYPE_CODE,
    LIGHT_TYPE_CODE,
    LIGHT_DPID,
    SWITCH,
    WORK_MODE,
    TEMP,
    BRIGHT,
    HUE,
    SAT,
)

SCAN_INTERVAL = timedelta(seconds=20)

_LOGGER = logging.getLogger(__name__)
_LOGGER.info(__name__)

SCAN_INTERVAL = timedelta(seconds=1)

async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_devices: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None
) -> None:
    """Set up the sensor platform."""
    # We only want this platform to be set up via discovery.
    # logging.info('setup_platform', hass, config, add_entities, discovery_info)
    _LOGGER.info('setup_platform')
    #_LOGGER.info(f'ip={hass.data[DOMAIN]}')

    #if discovery_info is None:
    #    return

    switches = []
    for item in config.get('switches') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')
        switches.append(CozyLifeSwitch(client, hass, 'wippe1'))

    for item in config.get('switches2') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')

        # Create two entities for each switch, one for each rocker
        switches.append(CozyLifeSwitch(client, hass, 'wippe1'))
        switches.append(CozyLifeSwitch(client, hass, 'wippe2'))

    async_add_devices(switches)
    for switch in switches:
        await hass.async_add_executor_job(switch._tcp_client._initSocket)
        await asyncio.sleep(0.01)

    async def async_update(now=None):
        for switch in switches:
            await hass.async_add_executor_job(switch._refresh_state)
            await asyncio.sleep(0.01)
    async_track_time_interval(hass, async_update, SCAN_INTERVAL)

class CozyLifeSwitch(SwitchEntity):
    _tcp_client = None
    _attr_is_on = True
    _wippe = None  # Add a new attribute to track the rocker

    def __init__(self, tcp_client: tcp_client, hass, wippe: str) -> None:
        """Initialize the sensor."""
        _LOGGER.info('__init__')
        self.hass = hass
        self._tcp_client = tcp_client
        self._unique_id = tcp_client.device_id + '_' + wippe
        self._name = tcp_client.device_id[-4:] + ' ' + wippe
        self._wippe = wippe  # Set the rocker attribute
        self._refresh_state()

    @property
    def unique_id(self) -> str | None:
        """Return a unique ID."""
        return self._unique_id

    async def async_update(self):
        await self.hass.async_add_executor_job(self._refresh_state)

    def _refresh_state(self):
        self._state = self._tcp_client.query()
        _LOGGER.info(f'_name={self._name},_state={self._state}')
        if self._state:
            if self._wippe == 'wippe1':
                self._attr_is_on = (self._state['1'] & 0x01) == 0x01
            elif self._wippe == 'wippe2':
                self._attr_is_on = (self._state['1'] & 0x02) == 0x02

    @property
    def name(self) -> str:
        return 'cozylife:' + self._name

    @property
    def available(self) -> bool:
        """Return if the device is available."""
        if self._tcp_client._connect:
            return True
        else:
            return False

    @property
    def is_on(self) -> bool:
        """Return True if entity is on."""
        return self._attr_is_on

    async def async_turn_on(self, **kwargs: Any) -> None:
        """Turn the entity on."""
        self._attr_is_on = True

        _LOGGER.info(f'turn_on:{kwargs}')

        if self._wippe == 'wippe1':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] | 0x01
            })
        elif self._wippe == 'wippe2':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] | 0x02
            })

        return None

    async def async_turn_off(self, **kwargs: Any) -> None:
        """Turn the entity off."""
        self._attr_is_on = False

        _LOGGER.info('turn_off')

        if self._wippe == 'wippe1':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] & ~0x01
            })
        elif self._wippe == 'wippe2':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] & ~0x02
            })

        return None

```````_

adjust SCAN_INTERVAL = timedelta(seconds=1)  (in my case 1 second) to improve status response speed of the switch. Going below 1 sec. could maybe decrease your homeassistant's performance.
"wippe" stands for rocker

I found information about how to control certain rockers here:

https://github.com/cozylife/dpid_document/blob/main/electrician_zh.md
neyestrabelli commented 5 months ago

Thanks!

I modify your code to use my 3 gang switch too.

configuration.yaml


switch:
- platform: cozylife
  switches3:
  - ip: 192.168.3.20
    did: 29470490a0764e71f5b4
    pid: c3xzf5
    dmn: Bathroom 1
    dpid: [1, 2, 3, 4, 5, 6, 7]
  - ip: 192.168.3.75
    did: 97138815a0764e51621c
    pid: c3xzf5
    dmn:  Bathroom 2
    dpid: [1, 2, 3, 4, 5, 6, 7]
  switches2:
  - ip: 192.168.3.9
    did: 1275753884f7033e8320
    pid: r8i69w
    dmn: TV Room
    dpid: [1, 2, 3, 4, 5]

Here is the code of custom_components/cozylife/switch.py:

"""Platform for sensor integration."""
from __future__ import annotations
import logging
from .tcp_client import tcp_client
from datetime import timedelta
import asyncio

from homeassistant.components.sensor import SensorEntity
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import TEMP_CELSIUS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.event import async_track_time_interval

from typing import Any, Final, Literal, TypedDict, final
from .const import (
    DOMAIN,
    SWITCH_TYPE_CODE,
    LIGHT_TYPE_CODE,
    LIGHT_DPID,
    SWITCH,
    WORK_MODE,
    TEMP,
    BRIGHT,
    HUE,
    SAT,
)

SCAN_INTERVAL = timedelta(seconds=20)

_LOGGER = logging.getLogger(__name__)
_LOGGER.info(__name__)

SCAN_INTERVAL = timedelta(seconds=1)

async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_devices: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None
) -> None:
    """Set up the sensor platform."""
    # We only want this platform to be set up via discovery.
    # logging.info('setup_platform', hass, config, add_entities, discovery_info)
    _LOGGER.info('setup_platform')
    #_LOGGER.info(f'ip={hass.data[DOMAIN]}')

    #if discovery_info is None:
    #    return

    switches = []
    for item in config.get('switches') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')
        switches.append(CozyLifeSwitch(client, hass, 'switch1'))

    for item in config.get('switches2') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')

        # Create two entities for each switch, one for each rocker
        switches.append(CozyLifeSwitch(client, hass, 'switch1'))
        switches.append(CozyLifeSwitch(client, hass, 'switch2'))

    for item in config.get('switches3') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')

        # Create two entities for each switch, one for each rocker
        switches.append(CozyLifeSwitch(client, hass, 'switch1'))
        switches.append(CozyLifeSwitch(client, hass, 'switch2'))
        switches.append(CozyLifeSwitch(client, hass, 'switch3'))

    async_add_devices(switches)
    for switch in switches:
        await hass.async_add_executor_job(switch._tcp_client._initSocket)
        await asyncio.sleep(0.01)

    async def async_update(now=None):
        for switch in switches:
            await hass.async_add_executor_job(switch._refresh_state)
            await asyncio.sleep(0.01)
    async_track_time_interval(hass, async_update, SCAN_INTERVAL)

class CozyLifeSwitch(SwitchEntity):
    _tcp_client = None
    _attr_is_on = True
    _wippe = None  # Add a new attribute to track the rocker

    def __init__(self, tcp_client: tcp_client, hass, wippe: str) -> None:
        """Initialize the sensor."""
        _LOGGER.info('__init__')
        self.hass = hass
        self._tcp_client = tcp_client
        self._unique_id = tcp_client.device_id + '_' + wippe
        self._name = tcp_client.device_id[-4:] + ' ' + wippe
        self._wippe = wippe  # Set the rocker attribute
        self._refresh_state()

    @property
    def unique_id(self) -> str | None:
        """Return a unique ID."""
        return self._unique_id

    async def async_update(self):
        await self.hass.async_add_executor_job(self._refresh_state)

    def _refresh_state(self):
        self._state = self._tcp_client.query()
        _LOGGER.info(f'_name={self._name},_state={self._state}')
        if self._state:
            if self._wippe == 'switch1':
                self._attr_is_on = (self._state['1'] & 0x01) == 0x01
            elif self._wippe == 'switch2':
                self._attr_is_on = (self._state['1'] & 0x02) == 0x02
            elif self._wippe == 'switch3':
                self._attr_is_on = (self._state['1'] & 0x04) == 0x04

    @property
    def name(self) -> str:
        return 'cozylife:' + self._name

    @property
    def available(self) -> bool:
        """Return if the device is available."""
        if self._tcp_client._connect:
            return True
        else:
            return False

    @property
    def is_on(self) -> bool:
        """Return True if entity is on."""
        return self._attr_is_on

    async def async_turn_on(self, **kwargs: Any) -> None:
        """Turn the entity on."""
        self._attr_is_on = True

        _LOGGER.info(f'turn_on:{kwargs}')

        if self._wippe == 'switch1':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] | 0x01
            })
        elif self._wippe == 'switch2':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] | 0x02
            })
        elif self._wippe == 'switch3':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] | 0x04
            })

        return None

    async def async_turn_off(self, **kwargs: Any) -> None:
        """Turn the entity off."""
        self._attr_is_on = False

        _LOGGER.info('turn_off')

        if self._wippe == 'switch1':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] & ~0x01
            })
        elif self._wippe == 'switch2':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] & ~0x02
            })
        elif self._wippe == 'switch3':
            await self.hass.async_add_executor_job(self._tcp_client.control, {
                '1': self._state['1'] & ~0x04
            })

        return None
BonefaceJohnson commented 5 months ago

Great! Sometimes while switching, i receive this error: Failed to call service switch/turn_off. 'NoneType' object is not subscriptable

does someone have a idea on how to solve this.

neyestrabelli commented 5 months ago

Maybe the interval, I changed to 3 seconds and don't receive anymore.

soulripper13 commented 5 months ago

Does anybody have this in theit logs too?

Logger: homeassistant.components.switch Source: helpers/entity_platform.py:1033 integration: Switch (documentation, issues) First occurred: 7:48:52 PM (1233 occurrences) Last logged: 11:33:45 PM

Updating cozylife switch took longer than the scheduled update interval 0:00:03

BonefaceJohnson commented 5 months ago

yes, very often, about 4000 times in two hours. But i belief it's nothing dramatic.

neyestrabelli commented 4 months ago

Today I conducted more tests. This message about the interval and NoneType is due to a socket timeout.

I enabled the info log, and here is what I found:

2024-06-25 11:30:10.151 WARNING (MainThread) [homeassistant.components.switch] Updating cozylife switch took longer than the scheduled update interval 0:00:03
2024-06-25 11:30:11.457 INFO (SyncWorker_28) [custom_components.cozylife.tcp_client] _only_send.recv.error:timed out
2024-06-25 11:30:11.458 INFO (SyncWorker_28) [custom_components.cozylife.switch] _name=69f8 switch2,_state=None

I created an automation to turn off all switches, but sometimes one remains on due to the timeout when checking the state.

I can't find why the socket doesn't respond.

soulripper13 commented 4 months ago

I tried fixing the error messages but no avail. If I increase the scan interval from 3 to 5 or 10 I get the following too: Failed to call service switch/turn_off. 'NoneType' object is not subscriptable but with 3 seconds I just get

WARNING (MainThread) [homeassistant.components.switch] Updating cozylife switch took longer than the scheduled update interval 0:00:03 I left it at 3 as long as it's working

BonefaceJohnson commented 1 month ago

I further improved the code, so the state of switches get updated very fast and they reconnect quickly, if the connection was lost.

USE THIS CODE:

from __future__ import annotations
import logging
from .tcp_client import tcp_client
from datetime import timedelta
import asyncio

from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.event import async_track_time_interval

from typing import Any
from .const import (
    DOMAIN,
    SWITCH_TYPE_CODE,
)

SCAN_INTERVAL = timedelta(seconds=0.7)

_LOGGER = logging.getLogger(__name__)

async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_devices: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None
) -> None:
    """Set up the sensor platform."""
    _LOGGER.info('setup_platform')

    switches = []
    for item in config.get('switches') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')
        switches.append(CozyLifeSwitch(client, hass, 'wippe1'))

    for item in config.get('switches2') or []:
        client = tcp_client(item.get('ip'))
        client._device_id = item.get('did')
        client._pid = item.get('pid')
        client._dpid = item.get('dpid')
        client._device_model_name = item.get('dmn')
        switches.append(CozyLifeSwitch(client, hass, 'wippe1'))
        switches.append(CozyLifeSwitch(client, hass, 'wippe2'))

    async_add_devices(switches)

    for switch in switches:
        await hass.async_add_executor_job(switch._tcp_client._initSocket)
        await asyncio.sleep(0.01)

    async def async_update(now=None):
        for switch in switches:
            await switch.async_update()
            await asyncio.sleep(0.01)

    async_track_time_interval(hass, async_update, SCAN_INTERVAL)

class CozyLifeSwitch(SwitchEntity):
    def __init__(self, tcp_client: tcp_client, hass: HomeAssistant, wippe: str) -> None:
        """Initialize the sensor."""
        self.hass = hass
        self._tcp_client = tcp_client
        self._unique_id = f"{tcp_client.device_id}_{wippe}"
        self._name = f"cozylife:{tcp_client.device_id[-4:]}_{wippe}"
        self._wippe = wippe
        self._attr_is_on = False
        self._state = {}
        self._available = True
        self._reconnect_delay = 0.5  # Initial reconnect delay

        # Event listener setup
        self._event_listener_task: asyncio.Task | None = None
        self.async_on_remove(self.stop_event_listener)

    async def async_added_to_hass(self) -> None:
        """Run when entity about to be added to hass."""
        await super().async_added_to_hass()
        self.start_event_listener()

    def start_event_listener(self) -> None:
        """Start the event listener."""
        if self._event_listener_task is None:
            self._event_listener_task = self.hass.loop.create_task(self._listen_for_events())

    async def stop_event_listener(self) -> None:
        """Stop the event listener."""
        if self._event_listener_task:
            self._event_listener_task.cancel()
            try:
                await self._event_listener_task
            except asyncio.CancelledError:
                pass
            self._event_listener_task = None

    async def _listen_for_events(self) -> None:
        """Listen for events from the CozyLife device."""
        while True:
            try:
                event = await self.hass.async_add_executor_job(self._tcp_client.query)
                if event:
                    self._state = event
                    self._update_state()
                    self.async_write_ha_state()
                await asyncio.sleep(0.1)
            except Exception as e:
                _LOGGER.error(f"Error listening for events: {e}")
                self._available = False
                self.async_write_ha_state()
                await self._tcp_client._initSocket()  # Reinitialize socket

    def _update_state(self) -> None:
        """Update the switch state based on the received event."""
        if self._wippe == 'wippe1':
            self._attr_is_on = (self._state.get('1', 0) & 0x01) == 0x01
        elif self._wippe == 'wippe2':
            self._attr_is_on = (self._state.get('1', 0) & 0x02) == 0x02

    async def async_update(self) -> None:
        """Fetch new state data for the sensor."""
        await self.hass.async_add_executor_job(self._refresh_state)

    async def _refresh_state(self) -> None:
        """Refresh the switch state."""
        try:
            self._state = self._tcp_client.query()
            _LOGGER.info(f'_name={self._name}, _state={self._state}')
            if self._state:
                self._update_state()
                self._available = True
            else:
                self._available = False
                await self._tcp_client._initSocket()  # Reinitialize socket
        except Exception as e:
            _LOGGER.error(f"Error refreshing state: {e}")
            self._available = False
            await self._tcp_client._initSocket()  # Reinitialize socket

    @property
    def unique_id(self) -> str:
        """Return a unique ID."""
        return self._unique_id

    @property
    def name(self) -> str:
        """Return the name of the entity."""
        return self._name

    @property
    def available(self) -> bool:
        """Return if the device is available."""
        return self._available

    @property
    def is_on(self) -> bool:
        """Return True if entity is on."""
        return self._attr_is_on

    async def async_turn_on(self, **kwargs: Any) -> None:
        """Turn the entity on."""
        new_state = self._state.get('1', 0)
        if self._wippe == 'wippe1':
            new_state |= 0x01
        elif self._wippe == 'wippe2':
            new_state |= 0x02
        await self.hass.async_add_executor_job(self._tcp_client.control, {'1': new_state})
        await self.async_update()

    async def async_turn_off(self, **kwargs: Any) -> None:
        """Turn the entity off."""
        new_state = self._state.get('1', 0)
        if self._wippe == 'wippe1':
            new_state &= ~0x01
        elif self._wippe == 'wippe2':
            new_state &= ~0x02
        await self.hass.async_add_executor_job(self._tcp_client.control, {'1': new_state})
        await self.async_update()
BonefaceJohnson commented 1 month ago

Great! Sometimes while switching, i receive this error: Failed to call service switch/turn_off. 'NoneType' object is not subscriptable

does someone have a idea on how to solve this.

fixed this in the improved code

BonefaceJohnson commented 1 month ago

I tried fixing the error messages but no avail. If I increase the scan interval from 3 to 5 or 10 I get the following too: Failed to call service switch/turn_off. 'NoneType' object is not subscriptable but with 3 seconds I just get

WARNING (MainThread) [homeassistant.components.switch] Updating cozylife switch took longer than the scheduled update interval 0:00:03 I left it at 3 as long as it's working

check out my updated code, should be fixed

BonefaceJohnson commented 1 month ago

Today I conducted more tests. This message about the interval and NoneType is due to a socket timeout.

I enabled the info log, and here is what I found:

2024-06-25 11:30:10.151 WARNING (MainThread) [homeassistant.components.switch] Updating cozylife switch took longer than the scheduled update interval 0:00:03
2024-06-25 11:30:11.457 INFO (SyncWorker_28) [custom_components.cozylife.tcp_client] _only_send.recv.error:timed out
2024-06-25 11:30:11.458 INFO (SyncWorker_28) [custom_components.cozylife.switch] _name=69f8 switch2,_state=None

I created an automation to turn off all switches, but sometimes one remains on due to the timeout when checking the state.

I can't find why the socket doesn't respond.

check out the updated code

neyestrabelli commented 1 month ago

Thank you, but I removed this plugin and tested it with the HomeKit integration, which works much better. I’m also sharing it with MatterBridge.