Expose battery and rssi sensors in Melnor Bluetooth integration (#77576)
This commit is contained in:
parent
98441e8620
commit
e1150ce190
8 changed files with 319 additions and 71 deletions
|
@ -14,7 +14,10 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||
from .const import DOMAIN
|
||||
from .models import MelnorDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
|
|
@ -43,6 +43,7 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
|
|||
"""Base class for melnor entities."""
|
||||
|
||||
_device: Device
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -59,8 +60,6 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
|
|||
model=self._device.model,
|
||||
name=self._device.name,
|
||||
)
|
||||
self._attr_name = self._device.name
|
||||
self._attr_unique_id = self._device.mac
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
|
|
101
homeassistant/components/melnor/sensor.py
Normal file
101
homeassistant/components/melnor/sensor.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
"""Support for Melnor RainCloud sprinkler water timer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from melnor_bluetooth.device import Device
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .models import MelnorBluetoothBaseEntity, MelnorDataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class MelnorSensorEntityDescriptionMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
state_fn: Callable[[Device], Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MelnorSensorEntityDescription(
|
||||
SensorEntityDescription, MelnorSensorEntityDescriptionMixin
|
||||
):
|
||||
"""Describes Melnor sensor entity."""
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_devices: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
|
||||
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
sensors: list[MelnorSensorEntityDescription] = [
|
||||
MelnorSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
key="battery",
|
||||
name="Battery",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_fn=lambda device: device.battery_level,
|
||||
),
|
||||
MelnorSensorEntityDescription(
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
key="rssi",
|
||||
name="RSSI",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_fn=lambda device: device.rssi,
|
||||
),
|
||||
]
|
||||
|
||||
async_add_devices(
|
||||
MelnorSensorEntity(
|
||||
coordinator,
|
||||
description,
|
||||
)
|
||||
for description in sensors
|
||||
)
|
||||
|
||||
|
||||
class MelnorSensorEntity(MelnorBluetoothBaseEntity, SensorEntity):
|
||||
"""Representation of a Melnor sensor."""
|
||||
|
||||
entity_description: MelnorSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MelnorDataUpdateCoordinator,
|
||||
entity_description: MelnorSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a sensor for a Melnor device."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = f"{self._device.mac}-{entity_description.key}"
|
||||
|
||||
self.entity_description = entity_description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the battery level."""
|
||||
return self.entity_description.state_fn(self._device)
|
|
@ -30,13 +30,12 @@ async def async_setup_entry(
|
|||
if coordinator.data[f"zone{i}"] is not None:
|
||||
switches.append(MelnorSwitch(coordinator, i))
|
||||
|
||||
async_add_devices(switches, True)
|
||||
async_add_devices(switches)
|
||||
|
||||
|
||||
class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity):
|
||||
"""A switch implementation for a melnor device."""
|
||||
|
||||
_valve_index: int
|
||||
_attr_icon = "mdi:sprinkler"
|
||||
|
||||
def __init__(
|
||||
|
@ -48,8 +47,9 @@ class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity):
|
|||
super().__init__(coordinator)
|
||||
self._valve_index = valve_index
|
||||
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-zone{self._valve().id}-manual"
|
||||
self._attr_name = f"{self._device.name} Zone {self._valve().id+1}"
|
||||
valve_id = self._valve().id
|
||||
self._attr_name = f"Zone {valve_id+1}"
|
||||
self._attr_unique_id = f"{self._device.mac}-zone{valve_id}-manual"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
|
|
@ -1,64 +1 @@
|
|||
"""Tests for the melnor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
||||
|
||||
FAKE_ADDRESS_1 = "FAKE-ADDRESS-1"
|
||||
FAKE_ADDRESS_2 = "FAKE-ADDRESS-2"
|
||||
|
||||
|
||||
FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak(
|
||||
name="YM_TIMER%",
|
||||
address=FAKE_ADDRESS_1,
|
||||
rssi=-63,
|
||||
manufacturer_data={
|
||||
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(FAKE_ADDRESS_1, None),
|
||||
advertisement=AdvertisementData(local_name=""),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
||||
|
||||
FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak(
|
||||
name="YM_TIMER%",
|
||||
address=FAKE_ADDRESS_2,
|
||||
rssi=-63,
|
||||
manufacturer_data={
|
||||
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(FAKE_ADDRESS_2, None),
|
||||
advertisement=AdvertisementData(local_name=""),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
||||
|
||||
|
||||
def patch_async_setup_entry(return_value=True):
|
||||
"""Patch async setup entry to return True."""
|
||||
return patch(
|
||||
"homeassistant.components.melnor.async_setup_entry",
|
||||
return_value=return_value,
|
||||
)
|
||||
|
||||
|
||||
def patch_async_discovered_service_info(
|
||||
return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1],
|
||||
):
|
||||
"""Patch async_discovered_service_info a mocked device info."""
|
||||
return patch(
|
||||
"homeassistant.components.melnor.config_flow.async_discovered_service_info",
|
||||
return_value=return_value,
|
||||
)
|
||||
|
|
139
tests/components/melnor/conftest.py
Normal file
139
tests/components/melnor/conftest.py
Normal file
|
@ -0,0 +1,139 @@
|
|||
"""Tests for the melnor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from melnor_bluetooth.device import Device, Valve
|
||||
|
||||
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
||||
from homeassistant.components.melnor.const import DOMAIN
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
FAKE_ADDRESS_1 = "FAKE-ADDRESS-1"
|
||||
FAKE_ADDRESS_2 = "FAKE-ADDRESS-2"
|
||||
|
||||
|
||||
FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak(
|
||||
name="YM_TIMER%",
|
||||
address=FAKE_ADDRESS_1,
|
||||
rssi=-63,
|
||||
manufacturer_data={
|
||||
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(FAKE_ADDRESS_1, None),
|
||||
advertisement=AdvertisementData(local_name=""),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
||||
|
||||
FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak(
|
||||
name="YM_TIMER%",
|
||||
address=FAKE_ADDRESS_2,
|
||||
rssi=-63,
|
||||
manufacturer_data={
|
||||
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
|
||||
},
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
source="local",
|
||||
device=BLEDevice(FAKE_ADDRESS_2, None),
|
||||
advertisement=AdvertisementData(local_name=""),
|
||||
time=0,
|
||||
connectable=True,
|
||||
)
|
||||
|
||||
|
||||
def mock_config_entry(hass: HomeAssistant):
|
||||
"""Return a mock config entry."""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=FAKE_ADDRESS_1,
|
||||
data={CONF_ADDRESS: FAKE_ADDRESS_1},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def mock_melnor_valve(identifier: int):
|
||||
"""Return a mocked Melnor valve."""
|
||||
valve = Mock(spec=Valve)
|
||||
valve.id = identifier
|
||||
|
||||
return valve
|
||||
|
||||
|
||||
def mock_melnor_device():
|
||||
"""Return a mocked Melnor device."""
|
||||
|
||||
with patch("melnor_bluetooth.device.Device") as mock:
|
||||
|
||||
device = mock.return_value
|
||||
|
||||
device.connect = AsyncMock(return_value=True)
|
||||
device.disconnect = AsyncMock(return_value=True)
|
||||
device.fetch_state = AsyncMock(return_value=device)
|
||||
|
||||
device.battery_level = 80
|
||||
device.mac = FAKE_ADDRESS_1
|
||||
device.model = "test_model"
|
||||
device.name = "test_melnor"
|
||||
device.rssi = -50
|
||||
|
||||
device.zone1 = mock_melnor_valve(1)
|
||||
device.zone2 = mock_melnor_valve(2)
|
||||
device.zone3 = mock_melnor_valve(3)
|
||||
device.zone4 = mock_melnor_valve(4)
|
||||
|
||||
device.__getitem__.side_effect = lambda key: getattr(device, key)
|
||||
|
||||
return device
|
||||
|
||||
|
||||
def patch_async_setup_entry(return_value=True):
|
||||
"""Patch async setup entry to return True."""
|
||||
return patch(
|
||||
"homeassistant.components.melnor.async_setup_entry",
|
||||
return_value=return_value,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
def patch_async_discovered_service_info(
|
||||
return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1],
|
||||
):
|
||||
"""Patch async_discovered_service_info a mocked device info."""
|
||||
return patch(
|
||||
"homeassistant.components.melnor.config_flow.async_discovered_service_info",
|
||||
return_value=return_value,
|
||||
)
|
||||
|
||||
|
||||
def patch_async_ble_device_from_address(
|
||||
return_value: BluetoothServiceInfoBleak | None = FAKE_SERVICE_INFO_1,
|
||||
):
|
||||
"""Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak."""
|
||||
return patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address",
|
||||
return_value=return_value,
|
||||
)
|
||||
|
||||
|
||||
def patch_melnor_device(device: Device = mock_melnor_device()):
|
||||
"""Patch melnor_bluetooth.device to return a mocked Melnor device."""
|
||||
return patch("homeassistant.components.melnor.Device", return_value=device)
|
||||
|
||||
|
||||
def patch_async_register_callback():
|
||||
"""Patch async_register_callback to return True."""
|
||||
return patch("homeassistant.components.bluetooth.async_register_callback")
|
|
@ -8,7 +8,7 @@ from homeassistant.components.melnor.const import DOMAIN
|
|||
from homeassistant.const import CONF_ADDRESS, CONF_MAC
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import (
|
||||
from .conftest import (
|
||||
FAKE_ADDRESS_1,
|
||||
FAKE_SERVICE_INFO_1,
|
||||
FAKE_SERVICE_INFO_2,
|
||||
|
|
69
tests/components/melnor/test_sensor.py
Normal file
69
tests/components/melnor/test_sensor.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
"""Test the Melnor sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT
|
||||
from homeassistant.helpers import entity_registry
|
||||
|
||||
from .conftest import (
|
||||
mock_config_entry,
|
||||
mock_melnor_device,
|
||||
patch_async_ble_device_from_address,
|
||||
patch_async_register_callback,
|
||||
patch_melnor_device,
|
||||
)
|
||||
|
||||
|
||||
async def test_battery_sensor(hass):
|
||||
"""Test the battery sensor."""
|
||||
|
||||
entry = mock_config_entry(hass)
|
||||
|
||||
with patch_async_ble_device_from_address(), patch_melnor_device(), patch_async_register_callback():
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
battery_sensor = hass.states.get("sensor.test_melnor_battery")
|
||||
assert battery_sensor.state == "80"
|
||||
assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE
|
||||
assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY
|
||||
assert battery_sensor.attributes["state_class"] == SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
async def test_rssi_sensor(hass):
|
||||
"""Test the rssi sensor."""
|
||||
|
||||
entry = mock_config_entry(hass)
|
||||
|
||||
device = mock_melnor_device()
|
||||
|
||||
with patch_async_ble_device_from_address(), patch_melnor_device(
|
||||
device
|
||||
), patch_async_register_callback():
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = f"sensor.{device.name}_rssi"
|
||||
|
||||
# Ensure the entity is disabled by default by checking the registry
|
||||
ent_registry = entity_registry.async_get(hass)
|
||||
|
||||
rssi_registry_entry = ent_registry.async_get(entity_id)
|
||||
|
||||
assert rssi_registry_entry is not None
|
||||
assert rssi_registry_entry.disabled_by is not None
|
||||
|
||||
# Enable the entity and assert everything else is working as expected
|
||||
ent_registry.async_update_entity(entity_id, disabled_by=None)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
rssi = hass.states.get(entity_id)
|
||||
|
||||
assert (
|
||||
rssi.attributes["unit_of_measurement"] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT
|
||||
)
|
||||
assert rssi.attributes["device_class"] == SensorDeviceClass.SIGNAL_STRENGTH
|
||||
assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT
|
Loading…
Add table
Add a link
Reference in a new issue