Add upnp sensor for IP, Status, and Uptime (#54780)

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>
This commit is contained in:
ehendrix23 2021-09-03 09:15:28 -06:00 committed by GitHub
parent ae9e3c237a
commit 4310a7d814
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 191 additions and 138 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from ipaddress import ip_address from ipaddress import ip_address
from typing import Any from typing import Any
@ -11,8 +12,10 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
from homeassistant.components.network import async_get_source_ip from homeassistant.components.network import async_get_source_ip
from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.network.const import PUBLIC_TARGET_IP
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
@ -191,6 +194,20 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@dataclass
class UpnpBinarySensorEntityDescription(BinarySensorEntityDescription):
"""A class that describes UPnP entities."""
format: str = "s"
@dataclass
class UpnpSensorEntityDescription(SensorEntityDescription):
"""A class that describes a sensor UPnP entities."""
format: str = "s"
class UpnpDataUpdateCoordinator(DataUpdateCoordinator): class UpnpDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to update data from UPNP device.""" """Define an object to update data from UPNP device."""
@ -221,14 +238,30 @@ class UpnpEntity(CoordinatorEntity):
"""Base class for UPnP/IGD entities.""" """Base class for UPnP/IGD entities."""
coordinator: UpnpDataUpdateCoordinator coordinator: UpnpDataUpdateCoordinator
entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription
def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None: def __init__(
self,
coordinator: UpnpDataUpdateCoordinator,
entity_description: UpnpSensorEntityDescription
| UpnpBinarySensorEntityDescription,
) -> None:
"""Initialize the base entities.""" """Initialize the base entities."""
super().__init__(coordinator) super().__init__(coordinator)
self._device = coordinator.device self._device = coordinator.device
self.entity_description = entity_description
self._attr_name = f"{coordinator.device.name} {entity_description.name}"
self._attr_unique_id = f"{coordinator.device.udn}_{entity_description.key}"
self._attr_device_info = { self._attr_device_info = {
"connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)}, "connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)},
"name": coordinator.device.name, "name": coordinator.device.name,
"manufacturer": coordinator.device.manufacturer, "manufacturer": coordinator.device.manufacturer,
"model": coordinator.device.model_name, "model": coordinator.device.model_name,
} }
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and (
self.coordinator.data.get(self.entity_description.key) or False
)

View File

@ -9,8 +9,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import UpnpDataUpdateCoordinator, UpnpEntity from . import UpnpBinarySensorEntityDescription, UpnpDataUpdateCoordinator, UpnpEntity
from .const import DOMAIN, LOGGER, WANSTATUS from .const import DOMAIN, LOGGER, WAN_STATUS
BINARYSENSOR_ENTITY_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = (
UpnpBinarySensorEntityDescription(
key=WAN_STATUS,
name="wan status",
),
)
async def async_setup_entry( async def async_setup_entry(
@ -23,10 +30,14 @@ async def async_setup_entry(
LOGGER.debug("Adding binary sensor") LOGGER.debug("Adding binary sensor")
sensors = [ async_add_entities(
UpnpStatusBinarySensor(coordinator), UpnpStatusBinarySensor(
] coordinator=coordinator,
async_add_entities(sensors) entity_description=entity_description,
)
for entity_description in BINARYSENSOR_ENTITY_DESCRIPTIONS
if coordinator.data.get(entity_description.key) is not None
)
class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity): class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity):
@ -37,18 +48,12 @@ class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
coordinator: UpnpDataUpdateCoordinator, coordinator: UpnpDataUpdateCoordinator,
entity_description: UpnpBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize the base sensor.""" """Initialize the base sensor."""
super().__init__(coordinator) super().__init__(coordinator=coordinator, entity_description=entity_description)
self._attr_name = f"{coordinator.device.name} wan status"
self._attr_unique_id = f"{coordinator.device.udn}_wanstatus"
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.get(WANSTATUS)
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.coordinator.data[WANSTATUS] == "Connected" return self.coordinator.data[self.entity_description.key] == "Connected"

View File

@ -18,9 +18,9 @@ PACKETS_SENT = "packets_sent"
TIMESTAMP = "timestamp" TIMESTAMP = "timestamp"
DATA_PACKETS = "packets" DATA_PACKETS = "packets"
DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{TIME_SECONDS}"
WANSTATUS = "wan_status" WAN_STATUS = "wan_status"
WANIP = "wan_ip" ROUTER_IP = "ip"
UPTIME = "uptime" ROUTER_UPTIME = "uptime"
KIBIBYTE = 1024 KIBIBYTE = 1024
UPDATE_INTERVAL = timedelta(seconds=30) UPDATE_INTERVAL = timedelta(seconds=30)
CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval" CONFIG_ENTRY_SCAN_INTERVAL = "scan_interval"
@ -31,3 +31,6 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=30).total_seconds()
ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" ST_IGD_V1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" ST_IGD_V2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2"
SSDP_SEARCH_TIMEOUT = 4 SSDP_SEARCH_TIMEOUT = 4
RAW_SENSOR = "raw_sensor"
DERIVED_SENSOR = "derived_sensor"

View File

@ -14,7 +14,6 @@ from async_upnp_client.profiles.igd import IgdDevice
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import ( from .const import (
@ -26,10 +25,10 @@ from .const import (
LOGGER as _LOGGER, LOGGER as _LOGGER,
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
ROUTER_IP,
ROUTER_UPTIME,
TIMESTAMP, TIMESTAMP,
UPTIME, WAN_STATUS,
WANIP,
WANSTATUS,
) )
@ -49,7 +48,6 @@ class Device:
"""Initialize UPnP/IGD device.""" """Initialize UPnP/IGD device."""
self._igd_device = igd_device self._igd_device = igd_device
self._device_updater = device_updater self._device_updater = device_updater
self.coordinator: DataUpdateCoordinator = None
@classmethod @classmethod
async def async_create_device( async def async_create_device(
@ -168,7 +166,7 @@ class Device:
) )
return { return {
WANSTATUS: values[0][0] if values[0] is not None else None, WAN_STATUS: values[0][0] if values[0] is not None else None,
UPTIME: values[0][2] if values[0] is not None else None, ROUTER_UPTIME: values[0][2] if values[0] is not None else None,
WANIP: values[1], ROUTER_IP: values[1],
} }

View File

@ -3,73 +3,111 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND, TIME_SECONDS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import UpnpDataUpdateCoordinator, UpnpEntity from . import UpnpDataUpdateCoordinator, UpnpEntity, UpnpSensorEntityDescription
from .const import ( from .const import (
BYTES_RECEIVED, BYTES_RECEIVED,
BYTES_SENT, BYTES_SENT,
DATA_PACKETS, DATA_PACKETS,
DATA_RATE_PACKETS_PER_SECOND, DATA_RATE_PACKETS_PER_SECOND,
DERIVED_SENSOR,
DOMAIN, DOMAIN,
KIBIBYTE, KIBIBYTE,
LOGGER, LOGGER,
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
RAW_SENSOR,
ROUTER_IP,
ROUTER_UPTIME,
TIMESTAMP, TIMESTAMP,
WAN_STATUS,
) )
SENSOR_TYPES = { SENSOR_ENTITY_DESCRIPTIONS: dict[str, tuple[UpnpSensorEntityDescription, ...]] = {
BYTES_RECEIVED: { RAW_SENSOR: (
"device_value_key": BYTES_RECEIVED, UpnpSensorEntityDescription(
"name": f"{DATA_BYTES} received", key=BYTES_RECEIVED,
"unit": DATA_BYTES, name=f"{DATA_BYTES} received",
"unique_id": BYTES_RECEIVED, icon="mdi:server-network",
"derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} received", native_unit_of_measurement=DATA_BYTES,
"derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, format="d",
"derived_unique_id": "KiB/sec_received", ),
}, UpnpSensorEntityDescription(
BYTES_SENT: { key=BYTES_SENT,
"device_value_key": BYTES_SENT, name=f"{DATA_BYTES} sent",
"name": f"{DATA_BYTES} sent", icon="mdi:server-network",
"unit": DATA_BYTES, native_unit_of_measurement=DATA_BYTES,
"unique_id": BYTES_SENT, format="d",
"derived_name": f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent", ),
"derived_unit": DATA_RATE_KIBIBYTES_PER_SECOND, UpnpSensorEntityDescription(
"derived_unique_id": "KiB/sec_sent", key=PACKETS_RECEIVED,
}, name=f"{DATA_PACKETS} received",
PACKETS_RECEIVED: { icon="mdi:server-network",
"device_value_key": PACKETS_RECEIVED, native_unit_of_measurement=DATA_PACKETS,
"name": f"{DATA_PACKETS} received", format="d",
"unit": DATA_PACKETS, ),
"unique_id": PACKETS_RECEIVED, UpnpSensorEntityDescription(
"derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} received", key=PACKETS_SENT,
"derived_unit": DATA_RATE_PACKETS_PER_SECOND, name=f"{DATA_PACKETS} sent",
"derived_unique_id": "packets/sec_received", icon="mdi:server-network",
}, native_unit_of_measurement=DATA_PACKETS,
PACKETS_SENT: { format="d",
"device_value_key": PACKETS_SENT, ),
"name": f"{DATA_PACKETS} sent", UpnpSensorEntityDescription(
"unit": DATA_PACKETS, key=ROUTER_IP,
"unique_id": PACKETS_SENT, name="External IP",
"derived_name": f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network",
"derived_unit": DATA_RATE_PACKETS_PER_SECOND, ),
"derived_unique_id": "packets/sec_sent", UpnpSensorEntityDescription(
}, key=ROUTER_UPTIME,
name="Uptime",
icon="mdi:server-network",
native_unit_of_measurement=TIME_SECONDS,
entity_registry_enabled_default=False,
format="d",
),
UpnpSensorEntityDescription(
key=WAN_STATUS,
name="wan status",
icon="mdi:server-network",
),
),
DERIVED_SENSOR: (
UpnpSensorEntityDescription(
key="KiB/sec_received",
name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} received",
icon="mdi:server-network",
native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND,
format=".1f",
),
UpnpSensorEntityDescription(
key="KiB/sent",
name=f"{DATA_RATE_KIBIBYTES_PER_SECOND} sent",
icon="mdi:server-network",
native_unit_of_measurement=DATA_RATE_KIBIBYTES_PER_SECOND,
format=".1f",
),
UpnpSensorEntityDescription(
key="packets/sec_received",
name=f"{DATA_RATE_PACKETS_PER_SECOND} received",
icon="mdi:server-network",
native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND,
format=".1f",
),
UpnpSensorEntityDescription(
key="packets/sent",
name=f"{DATA_RATE_PACKETS_PER_SECOND} sent",
icon="mdi:server-network",
native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND,
format=".1f",
),
),
} }
async def async_setup_platform(
hass: HomeAssistant, config, async_add_entities, discovery_info=None
) -> None:
"""Old way of setting up UPnP/IGD sensors."""
LOGGER.debug(
"async_setup_platform: config: %s, discovery: %s", config, discovery_info
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -80,50 +118,31 @@ async def async_setup_entry(
LOGGER.debug("Adding sensors") LOGGER.debug("Adding sensors")
sensors = [ entities = []
RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), entities.append(
RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), RawUpnpSensor(
RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), coordinator=coordinator,
RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), entity_description=entity_description,
DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]), )
DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]), for entity_description in SENSOR_ENTITY_DESCRIPTIONS[RAW_SENSOR]
DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]), if coordinator.data.get(entity_description.key) is not None
DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]), )
]
async_add_entities(sensors) entities.append(
DerivedUpnpSensor(
coordinator=coordinator,
entity_description=entity_description,
)
for entity_description in SENSOR_ENTITY_DESCRIPTIONS[DERIVED_SENSOR]
if coordinator.data.get(entity_description.key) is not None
)
async_add_entities(entities)
class UpnpSensor(UpnpEntity, SensorEntity): class UpnpSensor(UpnpEntity, SensorEntity):
"""Base class for UPnP/IGD sensors.""" """Base class for UPnP/IGD sensors."""
def __init__(
self,
coordinator: UpnpDataUpdateCoordinator,
sensor_type: dict[str, str],
) -> None:
"""Initialize the base sensor."""
super().__init__(coordinator)
self._sensor_type = sensor_type
self._attr_name = f"{coordinator.device.name} {sensor_type['name']}"
self._attr_unique_id = f"{coordinator.device.udn}_{sensor_type['unique_id']}"
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return "mdi:server-network"
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.get(
self._sensor_type["device_value_key"]
)
@property
def native_unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return self._sensor_type["unit"]
class RawUpnpSensor(UpnpSensor): class RawUpnpSensor(UpnpSensor):
"""Representation of a UPnP/IGD sensor.""" """Representation of a UPnP/IGD sensor."""
@ -131,30 +150,26 @@ class RawUpnpSensor(UpnpSensor):
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the device.""" """Return the state of the device."""
device_value_key = self._sensor_type["device_value_key"] value = self.coordinator.data[self.entity_description.key]
value = self.coordinator.data[device_value_key]
if value is None: if value is None:
return None return None
return format(value, "d") return format(value, self.entity_description.format)
class DerivedUpnpSensor(UpnpSensor): class DerivedUpnpSensor(UpnpSensor):
"""Representation of a UNIT Sent/Received per second sensor.""" """Representation of a UNIT Sent/Received per second sensor."""
def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None: entity_description: UpnpSensorEntityDescription
def __init__(
self,
coordinator: UpnpDataUpdateCoordinator,
entity_description: UpnpSensorEntityDescription,
) -> None:
"""Initialize sensor.""" """Initialize sensor."""
super().__init__(coordinator, sensor_type) super().__init__(coordinator=coordinator, entity_description=entity_description)
self._last_value = None self._last_value = None
self._last_timestamp = None self._last_timestamp = None
self._attr_name = f"{coordinator.device.name} {sensor_type['derived_name']}"
self._attr_unique_id = (
f"{coordinator.device.udn}_{sensor_type['derived_unique_id']}"
)
@property
def native_unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return self._sensor_type["derived_unit"]
def _has_overflowed(self, current_value) -> bool: def _has_overflowed(self, current_value) -> bool:
"""Check if value has overflowed.""" """Check if value has overflowed."""
@ -164,8 +179,7 @@ class DerivedUpnpSensor(UpnpSensor):
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the device.""" """Return the state of the device."""
# Can't calculate any derivative if we have only one value. # Can't calculate any derivative if we have only one value.
device_value_key = self._sensor_type["device_value_key"] current_value = self.coordinator.data[self.entity_description.key]
current_value = self.coordinator.data[device_value_key]
if current_value is None: if current_value is None:
return None return None
current_timestamp = self.coordinator.data[TIMESTAMP] current_timestamp = self.coordinator.data[TIMESTAMP]
@ -176,7 +190,7 @@ class DerivedUpnpSensor(UpnpSensor):
# Calculate derivative. # Calculate derivative.
delta_value = current_value - self._last_value delta_value = current_value - self._last_value
if self._sensor_type["unit"] == DATA_BYTES: if self.entity_description.native_unit_of_measurement == DATA_BYTES:
delta_value /= KIBIBYTE delta_value /= KIBIBYTE
delta_time = current_timestamp - self._last_timestamp delta_time = current_timestamp - self._last_timestamp
if delta_time.total_seconds() == 0: if delta_time.total_seconds() == 0:
@ -188,4 +202,4 @@ class DerivedUpnpSensor(UpnpSensor):
self._last_value = current_value self._last_value = current_value
self._last_timestamp = current_timestamp self._last_timestamp = current_timestamp
return format(derived, ".1f") return format(derived, self.entity_description.format)

View File

@ -10,10 +10,10 @@ from homeassistant.components.upnp.const import (
BYTES_SENT, BYTES_SENT,
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
ROUTER_IP,
ROUTER_UPTIME,
TIMESTAMP, TIMESTAMP,
UPTIME, WAN_STATUS,
WANIP,
WANSTATUS,
) )
from homeassistant.components.upnp.device import Device from homeassistant.components.upnp.device import Device
from homeassistant.util import dt from homeassistant.util import dt
@ -83,9 +83,9 @@ class MockDevice(Device):
"""Get connection status, uptime, and external IP.""" """Get connection status, uptime, and external IP."""
self.status_times_polled += 1 self.status_times_polled += 1
return { return {
WANSTATUS: "Connected", WAN_STATUS: "Connected",
UPTIME: 0, ROUTER_UPTIME: 0,
WANIP: "192.168.0.1", ROUTER_IP: "192.168.0.1",
} }
async def async_start(self) -> None: async def async_start(self) -> None: