From 65899064d48002e90b965e407d6743a49bdacf66 Mon Sep 17 00:00:00 2001 From: Devmel Apps Date: Tue, 8 Aug 2023 17:58:43 +0200 Subject: [PATCH] RF listening and AirSend sensors --- airsend.example.yaml | 35 ++++ configuration.example.yaml | 16 +- custom_components/airsend/__init__.py | 2 +- custom_components/airsend/cover.py | 13 +- custom_components/airsend/device.py | 87 +++++++++ custom_components/airsend/sensor.py | 253 ++++++++++++++++++++++++++ custom_components/airsend/switch.py | 13 +- 7 files changed, 398 insertions(+), 21 deletions(-) create mode 100644 airsend.example.yaml create mode 100644 custom_components/airsend/sensor.py diff --git a/airsend.example.yaml b/airsend.example.yaml new file mode 100644 index 0000000..55e1f62 --- /dev/null +++ b/airsend.example.yaml @@ -0,0 +1,35 @@ +#internal_url: http://192.168.0.21:33863/ +devices: + AirSend: + type: 0 + spurl: !secret spurl + sensors: 1 + bind: 1 + refresh: 300 + Original remote: + type: 1 + channel: + id: 13920 + source: 568745 + Nexus Temperature: + type: 1 + id: "1368_542_temp" + channel: + id: 1368 + source: 542 + Nexus RH: + type: 1 + id: "1368_542_rh" + channel: + id: 1368 + source: 542 + volet cuisine: + id: 9000 + type: 4098 + apiKey: !secret apiKey + prise somfy: + type: 4097 + spurl: !secret spurl + channel: + id: 13920 + source: 19259 diff --git a/configuration.example.yaml b/configuration.example.yaml index d3a519e..17d3872 100644 --- a/configuration.example.yaml +++ b/configuration.example.yaml @@ -2,22 +2,10 @@ # Configure a default setup of Home Assistant (frontend, api, etc) default_config: -airsend: - devices: - volet cuisine: - id: 9000 - type: 4098 - apiKey: !secret apiKey - prise somfy: - type: 4097 - spurl: !secret spurl - channel: - id: 13920 - source: 19259 - - group: !include groups.yaml automation: !include automations.yaml script: !include scripts.yaml scene: !include scenes.yaml +airsend: !include airsend.yaml + diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index afb270d..dd3349b 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.hassio import ( from homeassistant.const import CONF_INTERNAL_URL DOMAIN = "airsend" -AS_TYPE = ["switch", "cover", "button"] +AS_TYPE = ["button", "cover", "sensor", "switch"] async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up the AirSend component.""" diff --git a/custom_components/airsend/cover.py b/custom_components/airsend/cover.py index f568de0..d21d68f 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -35,10 +35,11 @@ class AirSendCover(CoverEntity): device: Device, ) -> None: """Initialize a cover device.""" + self._hass = hass self._device = device uname = DOMAIN + device.name self._unique_id = "_".join(x for x in uname) - self._closed = False + self._closed = None if device.is_cover_with_position: self._attr_current_cover_position = 50 @@ -63,8 +64,7 @@ class AirSendCover(CoverEntity): @property def extra_state_attributes(self): - """Return the device state attributes.""" - return None + return self._device.extra_state_attributes @property def assumed_state(self): @@ -74,6 +74,13 @@ class AirSendCover(CoverEntity): @property def is_closed(self): """Return if the cover is closed.""" + if self._device.is_async and self._hass: + component = self._hass.states.get(self.entity_id) + if component is not None: + if component.state == 'open' or component.state == 'on' or component.state == 'up': + self._closed = False + else: + self._closed = True return self._closed def open_cover(self, **kwargs: Any) -> None: diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 48205b1..0ce1318 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -27,6 +27,8 @@ class Device: self._wait = False self._channel = {} self._note = None + self._bind = None + self._refresh = 5 * 60 try: self._uid = options["id"] except KeyError: @@ -35,6 +37,8 @@ class Device: self._rtype = options["type"] except KeyError: pass + if self._rtype == 0: + self._channel = {"id": 1} try: self._apikey = options["apiKey"] except KeyError: @@ -55,12 +59,65 @@ class Device: self._note = options["note"] except KeyError: pass + try: + self._bind = int(options["bind"]) + except KeyError: + pass + try: + self._refresh = int(options["refresh"]) + except KeyError: + pass @property def name(self) -> str: """Return the name.""" return self._name + @property + def unique_channel_name(self) -> str: + if self._uid: + return self._uid + if self._channel: + result = str(self._channel['id']) + if result: + uniquefield = ['source', 'mac', 'seed'] + for field in uniquefield: + if field in self._channel: + result += "_" + result += str(self._channel[field]) + return result + return self._name + + @property + def extra_state_attributes(self): + if self._channel: + self._attrs = { + "channel": self._channel + } + return self._attrs + return None + + @property + def is_async(self) -> bool: + """Return if asynchronous state.""" + if self._wait == False: + return True + return False + + @property + def is_airsend(self) -> bool: + """Return if is an AirSend.""" + if self._rtype == 0: + return True + return False + + @property + def is_sensor(self) -> bool: + """Return if is a sensor to listen.""" + if self._rtype == 1: + return True + return False + @property def is_button(self) -> bool: """Return if is a button.""" @@ -89,6 +146,36 @@ class Device: return True return False + @property + def refresh_value(self) -> int: + """Return refresh value in seconds.""" + if type(self._refresh) is int and self._refresh > 0: + return self._refresh + return (5 * 60) + + def bind(self) -> bool: + """Bind a channel to listen.""" + ret = False + if self._serviceurl and self._spurl and type(self._bind) is int and self._bind > 0: + payload = ('{"channel":{"id": '+str(self._bind)+'},\"duration\":0,\"callback\":\"http://127.0.0.1/\"}') + headers = { + "Authorization": "Bearer " + self._spurl, + "content-type": "application/json", + "User-Agent": "hass_airsend", + } + try: + response = post( + self._serviceurl + "airsend/bind", + headers=headers, + data=payload, + timeout=6, + ) + if response.status_code == 200: + ret = True + except exceptions.RequestException: + pass + return ret + def transfer(self, note, entity_id = None) -> bool: """Send a command.""" status_code = 404 diff --git a/custom_components/airsend/sensor.py b/custom_components/airsend/sensor.py new file mode 100644 index 0000000..57aa085 --- /dev/null +++ b/custom_components/airsend/sensor.py @@ -0,0 +1,253 @@ +"""AirSend sensors.""" +from typing import Any +from datetime import timedelta + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX + +from .device import Device + +from . import DOMAIN +import logging +_LOGGER = logging.getLogger(DOMAIN) + +async def async_setup_platform( + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + if discovery_info is None: + return + for name, options in discovery_info[CONF_DEVICES].items(): + device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + if device.is_airsend: + entity = AirSendStateSensor(hass, device) + async_add_entities([entity]) + sensors = False + try: + sensors = eval(str(options["sensors"])) + except KeyError: + pass + if sensors == True: + entityTmp = AirSendTempSensor(hass, device) + entityIll = AirSendIllSensor(hass, device) + async_add_entities([entityTmp, entityIll]) + if device.is_sensor: + entity = AirSendAnySensor(hass, device) + async_add_entities([entity]) + +class AirSendAnySensor(SensorEntity): + """Representation of an AirSend device temperature.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: + """Initialize a sensor.""" + self._device = device + uname = DOMAIN + device.name + self._unique_id = "_".join(x for x in uname) + self.entity_id = generate_entity_id("sensor.{}", self._device.unique_channel_name, hass=hass) + + @property + def unique_id(self): + """Return unique identifier of device.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._device.name + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + return self._device.extra_state_attributes + + @property + def available(self): + return True + + @property + def should_poll(self) -> bool: + """Return the polling state.""" + return False + + +class AirSendStateSensor(BinarySensorEntity): + """Representation of an AirSend device.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: + """Initialize a sensor.""" + self.hass = hass + self._bind = None + self._device = device + uname = DOMAIN + device.name + "_state" + self._unique_id = "_".join(x for x in uname) + self._coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=uname, + update_method=self.async_update_data, + update_interval=timedelta(seconds=10), + ) + def null_callback(): + return + self._coordinator.async_add_listener(null_callback) + + @property + def unique_id(self): + """Return unique identifier of device.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._device.name + "_state" + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + """Cette entité""" + return BinarySensorDeviceClass.RUNNING + + @property + def available(self): + return True + + @property + def should_poll(self) -> bool: + """Return the polling state.""" + return False + + async def async_update_data(self): + """Register update callback.""" + self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) + note = {"method": "QUERY", "type": "STATE"} + await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) + await self.hass.async_add_executor_job( lambda: self._device.bind() ) + + +class AirSendTempSensor(SensorEntity): + """Representation of an AirSend device temperature.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: + """Initialize a sensor.""" + self._device = device + uname = DOMAIN + device.name + "_temp" + self._unique_id = "_".join(x for x in uname) + self._coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=uname, + update_method=self.async_update_data, + update_interval=timedelta(seconds=12), + ) + def null_callback(): + return + self._coordinator.async_add_listener(null_callback) + + @property + def unique_id(self): + """Return unique identifier of device.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._device.name + "_temp" + + @property + def available(self): + return True + + @property + def device_class(self) -> SensorDeviceClass | None: + """Cette entité""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return measurement unit.""" + return UnitOfTemperature.CELSIUS + + @property + def should_poll(self) -> bool: + """Return the polling state.""" + return False + + async def async_update_data(self): + """Register update callback.""" + self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) + note = {"method": "QUERY", "type": "TEMPERATURE"} + await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) + + +class AirSendIllSensor(SensorEntity): + """Representation of an AirSend device temperature.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: + """Initialize a sensor.""" + self._device = device + uname = DOMAIN + device.name + "_ill" + self._unique_id = "_".join(x for x in uname) + self._coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=uname, + update_method=self.async_update_data, + update_interval=timedelta(seconds=12), + ) + def null_callback(): + return + self._coordinator.async_add_listener(null_callback) + + @property + def unique_id(self): + """Return unique identifier of device.""" + return self._unique_id + + @property + def name(self): + """Return the name of the device if any.""" + return self._device.name + "_ill" + + @property + def available(self): + return True + + @property + def device_class(self) -> SensorDeviceClass | None: + """Cette entité""" + return SensorDeviceClass.ILLUMINANCE + + @property + def native_unit_of_measurement(self): + """Return measurement unit.""" + return LIGHT_LUX + + @property + def should_poll(self) -> bool: + """Return the polling state.""" + return False + + async def async_update_data(self): + """Register update callback.""" + self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) + note = {"method": "QUERY", "type": "ILLUMINANCE"} + await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index 965ebb7..bc94222 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -11,7 +11,6 @@ from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL from . import DOMAIN - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None ) -> None: @@ -36,10 +35,11 @@ class AirSendSwitch(SwitchEntity): device: Device, ) -> None: """Initialize a switch or light device.""" + self._hass = hass self._device = device uname = DOMAIN + device.name self._unique_id = "_".join(x for x in uname) - self._state = False + self._state = None @property def unique_id(self): @@ -63,7 +63,7 @@ class AirSendSwitch(SwitchEntity): @property def extra_state_attributes(self): """Return the device state attributes.""" - return None + return self._device.extra_state_attributes @property def assumed_state(self): @@ -72,6 +72,13 @@ class AirSendSwitch(SwitchEntity): @property def is_on(self): + if self._device.is_async and self._hass: + component = self._hass.states.get(self.entity_id) + if component is not None: + if component.state == 'on': + self._state = True + else: + self._state = False return self._state def turn_on(self, **kwargs: Any) -> None: