Skip to content
Open
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo
- Select your devices, for local connection, select `spurl`
- Click `Export YAML` to save the airsend.yaml
- In the `config` folder of Home Assistant, place the `airsend.yaml` file.
- Edit the file and add these lines
```yaml
devices:
...
AirSend Box:
type: 0
spurl: !secret spurl
sensors: true
```

3. **Edit the `secrets.yaml` File**:
- Add a line to the `secrets.yaml` file with the AirSend - Local IP - / - Password - (and IPv4 address).
Expand All @@ -28,19 +37,13 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo
```
- Replace `**************` with the AirSend Password, `fe80::xxxx:xxxx:xxxx:xxxx` with AirSend Local IP and `192.168.xxx.xxx` with the AirSend IPv4 address.

4. **Edit the `configuration.yaml` File**:
- Add the following line to the `configuration.yaml` file to include the `airsend.yaml` file:
```yaml
airsend: !include airsend.yaml
```

5. **Install the Custom Component**:
4. **Install the Custom Component**:
- In the Home Assistant terminal, run the following command to install the component:
```bash
wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend/master/install | bash -
```

6. **Restart Home Assistant and the AirSend Addon**:
5. **Restart Home Assistant and the AirSend Addon**:
- Restart Home Assistant.
- Restart the AirSend addon.

Expand Down
127 changes: 99 additions & 28 deletions custom_components/airsend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,108 @@
"""The AirSend component."""
import os
import logging

from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers import discovery
from homeassistant.components.hassio import (
get_addons_info,
)
from homeassistant.const import CONF_INTERNAL_URL

from homeassistant.components.hassio import get_addons_info

DOMAIN = "airsend"
AS_TYPE = ["button", "cover", "sensor", "switch"]
AS_PLATFORMS = ["cover", "switch", "button", "light", "sensor", "binary_sensor"]

_LOGGER = logging.getLogger(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Legacy YAML setup — no longer used for device loading."""
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AirSend from a config entry."""
from .coordinator import AirSendCoordinator
from .device import Device

hass.data.setdefault(DOMAIN, {})

internal_url = entry.data.get(CONF_INTERNAL_URL, "")
devices_config = entry.data.get("devices", {})

coordinators = {}
for name, options in devices_config.items():
device = Device(name, options, internal_url)
coordinator = AirSendCoordinator(hass, device)
coordinators[name] = coordinator

hass.data[DOMAIN][entry.entry_id] = {
"entry": entry.data,
"coordinators": coordinators,
}

await hass.config_entries.async_forward_entry_setups(entry, AS_PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, AS_PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok


def load_airsend_yaml(hass: HomeAssistant) -> dict:
"""Load airsend.yaml resolving !secret tags via secrets.yaml."""
import yaml as _yaml

config_dir = hass.config.config_dir
path = os.path.join(config_dir, "airsend.yaml")

if not os.path.exists(path):
_LOGGER.error("airsend.yaml not found at %s", path)
return {}

secrets = {}
secrets_path = os.path.join(config_dir, "secrets.yaml")
if os.path.exists(secrets_path):
try:
with open(secrets_path, "r", encoding="utf-8") as f:
secrets = _yaml.safe_load(f) or {}
except Exception as e:
_LOGGER.warning("Could not load secrets.yaml: %s", e)

def secret_constructor(loader, node):
key = loader.construct_scalar(node)
value = secrets.get(key)
if value is None:
_LOGGER.warning("Secret '%s' not found in secrets.yaml", key)
return ""
return value

loader_class = type("SecretLoader", (_yaml.SafeLoader,), {})
loader_class.add_constructor("!secret", secret_constructor)

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:
with open(path, "r", encoding="utf-8") as f:
data = _yaml.load(f, Loader=loader_class) # noqa: S506
_LOGGER.debug("airsend.yaml loaded with %d keys", len(data) if data else 0)
return data or {}
except Exception as e:
_LOGGER.error("Failed to load airsend.yaml: %s", e)
return {}


async def get_internal_url(hass: HomeAssistant) -> str:
"""Auto-detect internal URL of the AirSend addon."""
try:
addons_info = get_addons_info(hass)
for name, options in addons_info.items():
if "_airsend" in name:
ip = options.get("ip_address")
if ip:
return "http://" + str(ip) + ":33863/"
except Exception:
pass
if internalurl == "":
try:
addons_info = get_addons_info(hass)
for name, options in addons_info.items():
if "_airsend" in name:
ip = options["ip_address"]
if ip:
internalurl = "http://" + str(ip) + ":33863/"
except:
pass
if internalurl != "" and not internalurl.endswith('/'):
internalurl += "/"
config[DOMAIN][CONF_INTERNAL_URL] = internalurl
for plateform in AS_TYPE:
discovery.load_platform(hass, plateform, DOMAIN, config[DOMAIN].copy(), config)
return True
return ""
61 changes: 61 additions & 0 deletions custom_components/airsend/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""AirSend binary sensors — state monitoring for AirSend boxes (type 0)."""
import logging

from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .coordinator import AirSendCoordinator
from . import DOMAIN

_LOGGER = logging.getLogger(DOMAIN)


async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinators: dict[str, AirSendCoordinator] = (
hass.data[DOMAIN][entry.entry_id]["coordinators"]
)
entities = []
for name, coordinator in coordinators.items():
if coordinator.device.is_airsend:
entities.append(AirSendStateSensor(coordinator))
async_add_entities(entities)


class AirSendStateSensor(CoordinatorEntity, BinarySensorEntity):
"""Binary sensor representing the running state of an AirSend box."""

def __init__(self, coordinator: AirSendCoordinator) -> None:
super().__init__(coordinator)
self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_state"

@property
def unique_id(self):
return self._unique_id

@property
def name(self):
return self.coordinator.device.name + "_state"

@property
def device_class(self) -> BinarySensorDeviceClass:
return BinarySensorDeviceClass.RUNNING

@property
def available(self) -> bool:
return self.coordinator.data.get("available", True)

@property
def is_on(self) -> bool | None:
return self.coordinator.data.get("available", True)

@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device.device_info
Binary file added custom_components/airsend/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 29 additions & 32 deletions custom_components/airsend/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,73 +4,70 @@
from .device import Device

from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType

from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.const import CONF_INTERNAL_URL

from . import DOMAIN


async def async_setup_platform(
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> 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])
internal_url = entry.data.get(CONF_INTERNAL_URL, "")
devices_config = entry.data.get("devices", {})
entities = []
for name, options in devices_config.items():
device = Device(name, options, internal_url)
if device.is_button:
entity = AirSendButton(
hass,
device,
)
async_add_entities([entity])
entities.append(AirSendButton(hass, device))
async_add_entities(entities)


class AirSendButton(ButtonEntity):
"""Representation of an AirSend Button."""

def __init__(
self,
hass: HomeAssistant,
device: Device,
) -> None:
"""Initialize a button."""
def __init__(self, hass: HomeAssistant, device: Device) -> None:
self._device = device
uname = DOMAIN + device.name
self._unique_id = "_".join(x for x in uname)
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_button"
self._available = True

@property
def unique_id(self):
"""Return unique identifier of remote device."""
return self._unique_id

@property
def available(self):
return True
return self._available

@property
def should_poll(self):
"""No polling needed."""
return False

@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 None

@property
def assumed_state(self):
"""Return true if unable to access real state of entity."""
return True

def press(self, **kwargs: Any) -> None:
"""Handle the button press."""
@property
def device_info(self) -> DeviceInfo:
return self._device.device_info

async def async_press(self, **kwargs: Any) -> None:
note = {"method": 1, "type": 0, "value": "TOGGLE"}
if self._device.transfer(note, self.entity_id) == True:
self.schedule_update_ha_state()
result = await self._device.async_transfer(note, self.entity_id)
available = result is not False
if self._available != available:
self._available = available
self.async_write_ha_state()
Loading