diff --git a/README.md b/README.md index beccec6..6c9842c 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,16 @@ # AirSend Home Assistant -Component for sending radio commands (433-434Mhz) through the AirSend device. +Component for sending radio commands through the AirSend (RF433) or AirSend duo (RF433 & RF868). ## Installation -1. Add `airsend:` to your HA configuration (see configuration below). -2. Into the terminal, run `wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend/master/install | bash -` +1. Into the terminal, run `wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend/master/install | bash -` OR copy the `airsend` folder into your [custom_components folder](https://developers.home-assistant.io/docs/creating_integration_file_structure/#where-home-assistant-looks-for-integrations). -3. To allow a local LAN connection please install and start [hass_airsend-addon](https://github.com/devmel/hass_airsend-addon). -4. Restart Home Assistant +2. To allow a local LAN connection please install and start [hass_airsend-addon](https://github.com/devmel/hass_airsend-addon). +3. Restart Home Assistant +4. Add `airsend:` to your HA configuration (see configuration below). +5. Restart Home Assistant ## Configuration diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index 317180c..3bc15f2 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -2,17 +2,33 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.helpers import discovery -from homeassistant.const import ( - CONF_DEVICES, +from homeassistant.components.hassio import ( + async_get_addon_info, ) +from homeassistant.const import CONF_INTERNAL_URL DOMAIN = "airsend" -AS_TYPE = ['switch', 'cover', 'button'] +AS_TYPE = ["switch", "cover", "button"] -async def async_setup(hass : HomeAssistant, config : ConfigType): + +async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up the AirSend component.""" if DOMAIN not in config: return True + internalurl = "" + try: + internalurl = config[DOMAIN][CONF_INTERNAL_URL] + except KeyError: + pass + if internalurl == "": + try: + addon_info: dict = await async_get_addon_info(hass, "local_airsend") + ip = addon_info["ip_address"] + if ip: + internalurl = "http://" + str(ip) + ":33863/" + except KeyError: + pass + config[DOMAIN][CONF_INTERNAL_URL] = internalurl for plateform in AS_TYPE: - discovery.load_platform(hass, plateform, DOMAIN, config[DOMAIN][CONF_DEVICES].copy(), config) + discovery.load_platform(hass, plateform, DOMAIN, config[DOMAIN].copy(), config) return True diff --git a/custom_components/airsend/button.py b/custom_components/airsend/button.py index 17abcac..b05ec9e 100644 --- a/custom_components/airsend/button.py +++ b/custom_components/airsend/button.py @@ -1,84 +1,44 @@ """AirSend buttons.""" -import logging -import json from typing import Any -from requests import get, post + +from .device import Device + +from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.components.hassio import ( - async_get_addon_discovery_info, - async_get_addon_info, -) -from homeassistant.components.button import ( - ButtonEntity, -) -from . import ( - DOMAIN, -) -_LOGGER = logging.getLogger(DOMAIN) +from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL -async def async_setup_platform(hass : HomeAssistant, config : ConfigType, async_add_entities, discovery_info=None): - addons_url = "" - try: - addon_info: dict = await async_get_addon_info(hass, 'local_airsend') - ip = addon_info["ip_address"] - if ip: - addons_url = "http://"+str(ip)+":33863/" - # _LOGGER.warning("Addon '%s'", addon_info) - except: - pass +from . import 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.items(): - if options['type'] == 4096 : - id = "" - apiKey = "" - spurl = "" - channel = {} - note = {"method":1,"type":0,"value": "TOGGLE"} - try: - id = options['id'] - except KeyError: - pass - try: - apiKey = options['apiKey'] - except KeyError: - pass - try: - spurl = options['spurl'] - except KeyError: - pass - try: - channel = options['channel'] - except KeyError: - pass - try: - note = options['note'] - except KeyError: - pass - entity = AirSendButton(hass, name, id, options['type'], apiKey, addons_url, spurl, channel, note) + for name, options in discovery_info[CONF_DEVICES].items(): + device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + if device.is_button: + entity = AirSendButton( + hass, + device, + ) async_add_entities([entity]) - return class AirSendButton(ButtonEntity): """Representation of an AirSend Button.""" - def __init__(self, hass : HomeAssistant, name : str, id: str, type : int, apikey : str, addons_url : str, spurl : str, channel : dict, note : dict): + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: """Initialize a button.""" - self._name = name - self._id = id - self._type = type - self._apikey = apikey - self._addons_url = addons_url - self._spurl = spurl - self._channel = channel - self._note = note - uname = DOMAIN+name + self._device = device + uname = DOMAIN + device.name self._unique_id = "_".join(x for x in uname) - self._state = False @property def unique_id(self): @@ -97,7 +57,7 @@ class AirSendButton(ButtonEntity): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._device.name @property def extra_state_attributes(self): @@ -109,37 +69,8 @@ class AirSendButton(ButtonEntity): """Return true if unable to access real state of entity.""" return True - def press(self) -> None: + def press(self, **kwargs: Any) -> None: """Handle the button press.""" - command = "6" - note = self._note - url = "https://airsend.cloud/device/" + str(self._id) + "/command/" + command + "/" - payload = '{"wait": true, "channel":' + json.dumps(self._channel) + ', "thingnotes":{"notes":[' + json.dumps( - note) + ']}}' - self._action(url, payload, True) - - def _action(self, cloud_url : str, payload : str, new_state : bool): - status_code = 404 - if self._addons_url and self._spurl: - headers = {"Authorization": "Bearer "+self._spurl, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = post(self._addons_url+"airsend/transfer", headers=headers, data=payload, timeout=6) - status_code = 500 - jdata = json.loads(response.text) - if jdata["type"] < 0x100: - status_code = response.status_code - except: - pass - if status_code != 200 and self._apikey and cloud_url: - headers = {"Authorization": "Bearer "+self._apikey, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = get(cloud_url, headers=headers, timeout=10) - status_code = response.status_code - except: - pass - if status_code == 200: - self._state = new_state + note = {"method": 1, "type": 0, "value": "TOGGLE"} + if self._device.transfer(note): self.schedule_update_ha_state() - else: - _LOGGER.error("action error : "+str(status_code)) - raise Exception("action error : "+str(status_code)) diff --git a/custom_components/airsend/cover.py b/custom_components/airsend/cover.py index d5a4f73..d1a4b85 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -1,78 +1,47 @@ """AirSend switches.""" -import logging -import json from typing import Any -from requests import get, post + +from .device import Device + +from homeassistant.components.cover import CoverEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.components.hassio import ( - async_get_addon_discovery_info, - async_get_addon_info, -) -from homeassistant.components.cover import ( - CoverEntity, -) -from . import ( - DOMAIN, -) -_LOGGER = logging.getLogger(DOMAIN) +from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL -async def async_setup_platform(hass : HomeAssistant, config : ConfigType, async_add_entities, discovery_info=None): - addons_url = "" - try: - addon_info: dict = await async_get_addon_info(hass, 'local_airsend') - ip = addon_info["ip_address"] - if ip: - addons_url = "http://"+str(ip)+":33863/" - # _LOGGER.warning("Addon '%s'", addon_info) - except: - pass +from . import 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.items(): - if options['type'] == 4098: - id = "" - apiKey = "" - spurl = "" - channel = {} - try: - id = options['id'] - except KeyError: - pass - try: - apiKey = options['apiKey'] - except KeyError: - pass - try: - spurl = options['spurl'] - except KeyError: - pass - try: - channel = options['channel'] - except KeyError: - pass - entity = AirSendCover(hass, name, id, options['type'], apiKey, addons_url, spurl, channel) + for name, options in discovery_info[CONF_DEVICES].items(): + device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + if device.is_cover: + entity = AirSendCover( + hass, + device, + ) async_add_entities([entity]) - return class AirSendCover(CoverEntity): """Representation of an AirSend Cover.""" - def __init__(self, hass : HomeAssistant, name : str, id: str, type : int, apikey : str, addons_url : str, spurl : str, channel : dict): + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: """Initialize a cover device.""" - self._name = name - self._id = id - self._type = type - self._apikey = apikey - self._addons_url = addons_url - self._spurl = spurl - self._channel = channel - uname = DOMAIN+name + self._device = device + uname = DOMAIN + device.name self._unique_id = "_".join(x for x in uname) - self._state = False + self._closed = False + if device.is_cover_with_position: + self._attr_current_cover_position = 50 @property def unique_id(self): @@ -91,7 +60,7 @@ class AirSendCover(CoverEntity): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._device.name @property def extra_state_attributes(self): @@ -106,51 +75,42 @@ class AirSendCover(CoverEntity): @property def is_closed(self): """Return if the cover is closed.""" - return not self._state + return not self._closed def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - note = {"method":1,"type":0,"value": "UP"} - url = "https://airsend.cloud/device/"+str(self._id)+"/command/5/" - payload = '{"wait": true, "channel":'+json.dumps(self._channel)+', "thingnotes":{"notes":['+json.dumps(note)+']}}' - self._action(url, payload, False) + note = {"method": 1, "type": 0, "value": "UP"} + if self._device.transfer(note): + self._closed = False + if self._device.is_cover_with_position: + self._attr_current_cover_position = 100 + self.schedule_update_ha_state() def close_cover(self, **kwargs: Any) -> None: """Close cover.""" - note = {"method":1,"type":0,"value": "DOWN"} - url = "https://airsend.cloud/device/"+str(self._id)+"/command/4/" - payload = '{"wait": true, "channel":'+json.dumps(self._channel)+', "thingnotes":{"notes":['+json.dumps(note)+']}}' - self._action(url, payload, False) + note = {"method": 1, "type": 0, "value": "DOWN"} + if self._device.transfer(note): + self._closed = True + if self._device.is_cover_with_position: + self._attr_current_cover_position = 0 + self.schedule_update_ha_state() def stop_cover(self, **kwargs): """Stop the cover.""" - note = {"method":1,"type":0,"value": "STOP"} - url = "https://airsend.cloud/device/"+str(self._id)+"/command/3/" - payload = '{"wait": true, "channel":'+json.dumps(self._channel)+', "thingnotes":{"notes":['+json.dumps(note)+']}}' - self._action(url, payload, False) + note = {"method": 1, "type": 0, "value": "STOP"} + if self._device.transfer(note): + self._closed = False + if self._device.is_cover_with_position: + self._attr_current_cover_position = 50 + self.schedule_update_ha_state() - def _action(self, cloud_url : str, payload : str, new_state : bool): - status_code = 404 - if self._addons_url and self._spurl: - headers = {"Authorization": "Bearer "+self._spurl, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = post(self._addons_url+"airsend/transfer", headers=headers, data=payload, timeout=6) - status_code = 500 - jdata = json.loads(response.text) - if jdata["type"] < 0x100: - status_code = response.status_code - except: - pass - if status_code != 200 and self._apikey and cloud_url: - headers = {"Authorization": "Bearer "+self._apikey, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = get(cloud_url, headers=headers, timeout=10) - status_code = response.status_code - except: - pass - if status_code == 200: - self._state = new_state + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + position = int(kwargs["position"]) + note = {"method": 1, "type": 9, "value": position} + if self._device.transfer(note): + self._attr_current_cover_position = position + self._closed = False + if self._attr_current_cover_position == 0: + self._closed = True self.schedule_update_ha_state() - else: - _LOGGER.error("action error : "+str(status_code)) - raise Exception("action error : "+str(status_code)) diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py new file mode 100644 index 0000000..5fbb9d1 --- /dev/null +++ b/custom_components/airsend/device.py @@ -0,0 +1,172 @@ +"""AirSend device.""" +import logging +import json +from requests import get, post, exceptions +from . import DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +class Device: + """Representation of a Device.""" + + def __init__( + self, + name: str, + options, + serviceurl: str, + ) -> None: + """Initialize a device.""" + self._name = name + self._serviceurl = serviceurl + self._uid = None + self._rtype = None + self._apikey = None + self._spurl = None + self._channel = {} + self._note = None + try: + self._uid = options["id"] + except KeyError: + pass + try: + self._rtype = options["type"] + except KeyError: + pass + try: + self._apikey = options["apiKey"] + except KeyError: + pass + try: + self._spurl = options["spurl"] + except KeyError: + pass + try: + self._channel = options["channel"] + except KeyError: + pass + try: + self._note = options["note"] + except KeyError: + pass + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def is_button(self) -> bool: + """Return if is a button.""" + if self._rtype == 4096: + return True + return False + + @property + def is_cover(self) -> bool: + """Return if is a cover.""" + if self._rtype in (4098, 4099): + return True + return False + + @property + def is_cover_with_position(self) -> bool: + """Return if is a cover with position.""" + if self._rtype == 4099: + return True + return False + + @property + def is_switch(self) -> bool: + """Return if is a switch.""" + if self._rtype == 4097: + return True + return False + + def transfer(self, note) -> bool: + """Send a command.""" + status_code = 404 + if self._serviceurl and self._spurl: + jnote = json.dumps(note) + if ( + self._note is not None + and "method" in self._note + and "type" in self._note + and "value" in self._note + and "method" in note.keys() + and "type" in note.keys() + and "value" in note.keys() + and note["method"] == 1 and note["type"] == 0 + and (note["value"] == "TOGGLE" or note["value"] == 6) + ): + jnote = json.dumps(self._note) + payload = ( + '{"wait": true, "channel":' + + json.dumps(self._channel) + + ', "thingnotes":{"notes":[' + + jnote + + "]}}" + ) + headers = { + "Authorization": "Bearer " + self._spurl, + "content-type": "application/json", + "User-Agent": "hass_airsend", + } + try: + response = post( + self._serviceurl + "airsend/transfer", + headers=headers, + data=payload, + timeout=6, + ) + status_code = 500 + jdata = json.loads(response.text) + if jdata["type"] < 0x100: + status_code = response.status_code + except exceptions.RequestException: + pass + if status_code != 200 and self._apikey: + action = "command" + value = 6 + if ( + "method" in note.keys() + and "type" in note.keys() + and "value" in note.keys() + ): + if note["method"] == 1 and note["type"] == 0: + if note["value"] == "OFF": + value = 0 + if note["value"] == "ON": + value = 1 + if note["value"] == "STOP": + value = 3 + if note["value"] == "DOWN": + value = 4 + if note["value"] == "UP": + value = 5 + if note["method"] == 1 and note["type"] == 9: + action = "level" + value = int(note["value"]) + cloud_url = ( + "https://airsend.cloud/device/" + + str(self._uid) + + "/" + + action + + "/" + + str(value) + + "/" + ) + headers = { + "Authorization": "Bearer " + self._apikey, + "content-type": "application/json", + "User-Agent": "hass_airsend", + } + try: + response = get(cloud_url, headers=headers, timeout=10) + status_code = response.status_code + except exceptions.RequestException: + pass + if status_code == 200: + return True + _LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code) + raise Exception("Transfer error " + self.name + " : " + str(status_code)) diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index afbfa47..0473ced 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -1,82 +1,43 @@ """AirSend switches.""" -import logging -import json from typing import Any -from requests import get, post + +from .device import Device + +from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.components.hassio import ( - async_get_addon_discovery_info, - async_get_addon_info, -) -from homeassistant.components.switch import ( - SwitchEntity, -) -from . import ( - DOMAIN, -) -_LOGGER = logging.getLogger(DOMAIN) +from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL -async def async_setup_platform(hass : HomeAssistant, config : ConfigType, async_add_entities, discovery_info=None): - addons_url = "" - try: - addon_info: dict = await async_get_addon_info(hass, 'local_airsend') - ip = addon_info["ip_address"] - if ip: - addons_url = "http://"+str(ip)+":33863/" - # _LOGGER.warning("Addon '%s'", addon_info) - except: - pass +from . import 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.items(): - if options['type'] == 4097: - id = "" - apiKey = "" - spurl = "" - channel = {} - note = {"method":1,"type":0,"value": "TOGGLE"} - try: - id = options['id'] - except KeyError: - pass - try: - apiKey = options['apiKey'] - except KeyError: - pass - try: - spurl = options['spurl'] - except KeyError: - pass - try: - channel = options['channel'] - except KeyError: - pass - try: - note = options['note'] - except KeyError: - pass - entity = AirSendSwitch(hass, name, id, options['type'], apiKey, addons_url, spurl, channel, note) + for name, options in discovery_info[CONF_DEVICES].items(): + device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + if device.is_switch: + entity = AirSendSwitch( + hass, + device, + ) async_add_entities([entity]) - return class AirSendSwitch(SwitchEntity): """Representation of an AirSend Switch.""" - def __init__(self, hass : HomeAssistant, name : str, id: str, type : int, apikey : str, addons_url : str, spurl : str, channel : dict, note : dict): + def __init__( + self, + hass: HomeAssistant, + device: Device, + ) -> None: """Initialize a switch or light device.""" - self._name = name - self._id = id - self._type = type - self._apikey = apikey - self._addons_url = addons_url - self._spurl = spurl - self._channel = channel - self._note = note - uname = DOMAIN+name + self._device = device + uname = DOMAIN + device.name self._unique_id = "_".join(x for x in uname) self._state = False @@ -97,7 +58,7 @@ class AirSendSwitch(SwitchEntity): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._device.name @property def extra_state_attributes(self): @@ -115,42 +76,14 @@ class AirSendSwitch(SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - command = "1" - note = {"method":1,"type":0,"value": "ON"} - url = "https://airsend.cloud/device/"+str(self._id)+"/command/"+command+"/" - payload = '{"wait": true, "channel":'+json.dumps(self._channel)+', "thingnotes":{"notes":['+json.dumps(note)+']}}' - self._action(url, payload, True) + note = {"method": 1, "type": 0, "value": "ON"} + if self._device.transfer(note): + self._state = True + self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - command = "0" - note = {"method":1,"type":0,"value": "OFF"} - url = "https://airsend.cloud/device/"+str(self._id)+"/command/"+command+"/" - payload = '{"wait": true, "channel":'+json.dumps(self._channel)+', "thingnotes":{"notes":['+json.dumps(note)+']}}' - self._action(url, payload, False) - - def _action(self, cloud_url : str, payload : str, new_state : bool): - status_code = 404 - if self._addons_url and self._spurl: - headers = {"Authorization": "Bearer "+self._spurl, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = post(self._addons_url+"airsend/transfer", headers=headers, data=payload, timeout=6) - status_code = 500 - jdata = json.loads(response.text) - if jdata["type"] < 0x100: - status_code = response.status_code - except: - pass - if status_code != 200 and self._apikey and cloud_url: - headers = {"Authorization": "Bearer "+self._apikey, "content-type": "application/json", "User-Agent": "hass_airsend"} - try: - response = get(cloud_url, headers=headers, timeout=10) - status_code = response.status_code - except: - pass - if status_code == 200: - self._state = new_state + note = {"method": 1, "type": 0, "value": "OFF"} + if self._device.transfer(note): + self._state = False self.schedule_update_ha_state() - else: - _LOGGER.error("action error : "+str(status_code)) - raise Exception("action error : "+str(status_code)) diff --git a/icons/icon.png b/icons/icon.png index 7280984..28bf2c1 100644 Binary files a/icons/icon.png and b/icons/icon.png differ