Add upnp binary sensor for connectivity status (#54489)

* New binary sensor for connectivity

* Add binary_sensor

* New binary sensor for connectivity

* Add binary_sensor

* Handle values returned as None

* Small text update for Uptime

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Updates based on review

* Update homeassistant/components/upnp/binary_sensor.py

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>

* Further updates based on review

* Set device_class as a class atribute

* Create 1 combined data coordinator
and UpnpEntity class

* Updates on coordinator

* Update comment

* Fix in async_step_init for coordinator

* Add async_get_status to mocked device
and set times polled for each call seperately

* Updated to get device through coordinator
Check polling for each status call seperately

* Use collections.abc instead of Typing for Mapping

* Remove adding device to hass.data as coordinator
is now saved

* Removed setting _coordinator

* Added myself as codeowner

* Update type in __init__

* Removed attributes from binary sensor

* Fix async_unload_entry

* Add expected return value to is_on

Co-authored-by: Joakim Sørensen <hi@ludeeus.dev>
This commit is contained in:
ehendrix23 2021-08-17 12:23:41 -06:00 committed by GitHub
parent 5b75c8254b
commit 8bf79d61ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 222 additions and 130 deletions

View File

@ -545,7 +545,7 @@ homeassistant/components/upb/* @gwww
homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upc_connect/* @pvizeli @fabaff
homeassistant/components/upcloud/* @scop homeassistant/components/upcloud/* @scop
homeassistant/components/updater/* @home-assistant/core homeassistant/components/updater/* @home-assistant/core
homeassistant/components/upnp/* @StevenLooman homeassistant/components/upnp/* @StevenLooman @ehendrix23
homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/uptimerobot/* @ludeeus
homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/usgs_earthquakes_feed/* @exxamalte
homeassistant/components/utility_meter/* @dgomes homeassistant/components/utility_meter/* @dgomes

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 datetime import timedelta
from ipaddress import ip_address from ipaddress import ip_address
from typing import Any from typing import Any
@ -17,24 +18,30 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ( from .const import (
CONF_LOCAL_IP, CONF_LOCAL_IP,
CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_HOSTNAME,
CONFIG_ENTRY_SCAN_INTERVAL,
CONFIG_ENTRY_ST, CONFIG_ENTRY_ST,
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
DOMAIN_CONFIG, DOMAIN_CONFIG,
DOMAIN_DEVICES, DOMAIN_DEVICES,
DOMAIN_LOCAL_IP, DOMAIN_LOCAL_IP,
LOGGER as _LOGGER, LOGGER,
) )
from .device import Device from .device import Device
NOTIFICATION_ID = "upnp_notification" NOTIFICATION_ID = "upnp_notification"
NOTIFICATION_TITLE = "UPnP/IGD Setup" NOTIFICATION_TITLE = "UPnP/IGD Setup"
PLATFORMS = ["sensor"] PLATFORMS = ["binary_sensor", "sensor"]
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -50,7 +57,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType): async def async_setup(hass: HomeAssistant, config: ConfigType):
"""Set up UPnP component.""" """Set up UPnP component."""
_LOGGER.debug("async_setup, config: %s", config) LOGGER.debug("async_setup, config: %s", config)
conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
conf = config.get(DOMAIN, conf_default) conf = config.get(DOMAIN, conf_default)
local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP)
@ -73,7 +80,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up UPnP/IGD device from a config entry.""" """Set up UPnP/IGD device from a config entry."""
_LOGGER.debug("Setting up config entry: %s", entry.unique_id) LOGGER.debug("Setting up config entry: %s", entry.unique_id)
udn = entry.data[CONFIG_ENTRY_UDN] udn = entry.data[CONFIG_ENTRY_UDN]
st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name
@ -86,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@callback @callback
def device_discovered(info: Mapping[str, Any]) -> None: def device_discovered(info: Mapping[str, Any]) -> None:
nonlocal discovery_info nonlocal discovery_info
_LOGGER.debug( LOGGER.debug(
"Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION] "Device discovered: %s, at: %s", usn, info[ssdp.ATTR_SSDP_LOCATION]
) )
discovery_info = info discovery_info = info
@ -103,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await asyncio.wait_for(device_discovered_event.wait(), timeout=10) await asyncio.wait_for(device_discovered_event.wait(), timeout=10)
except asyncio.TimeoutError as err: except asyncio.TimeoutError as err:
_LOGGER.debug("Device not discovered: %s", usn) LOGGER.debug("Device not discovered: %s", usn)
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
finally: finally:
cancel_discovered_callback() cancel_discovered_callback()
@ -114,12 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
] ]
device = await Device.async_create_device(hass, location) device = await Device.async_create_device(hass, location)
# Save device.
hass.data[DOMAIN][DOMAIN_DEVICES][udn] = device
# Ensure entry has a unique_id. # Ensure entry has a unique_id.
if not entry.unique_id: if not entry.unique_id:
_LOGGER.debug( LOGGER.debug(
"Setting unique_id: %s, for config_entry: %s", "Setting unique_id: %s, for config_entry: %s",
device.unique_id, device.unique_id,
entry, entry,
@ -150,8 +154,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model=device.model_name, model=device.model_name,
) )
update_interval_sec = entry.options.get(
CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
update_interval = timedelta(seconds=update_interval_sec)
LOGGER.debug("update_interval: %s", update_interval)
coordinator = UpnpDataUpdateCoordinator(
hass,
device=device,
update_interval=update_interval,
)
# Save coordinator.
hass.data[DOMAIN][entry.entry_id] = coordinator
await coordinator.async_config_entry_first_refresh()
# Create sensors. # Create sensors.
_LOGGER.debug("Enabling sensors") LOGGER.debug("Enabling sensors")
hass.config_entries.async_setup_platforms(entry, PLATFORMS) hass.config_entries.async_setup_platforms(entry, PLATFORMS)
# Start device updater. # Start device updater.
@ -162,14 +182,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a UPnP/IGD device from a config entry.""" """Unload a UPnP/IGD device from a config entry."""
_LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) LOGGER.debug("Unloading config entry: %s", config_entry.unique_id)
udn = config_entry.data.get(CONFIG_ENTRY_UDN) if coordinator := hass.data[DOMAIN].pop(config_entry.entry_id, None):
if udn in hass.data[DOMAIN][DOMAIN_DEVICES]: await coordinator.device.async_stop()
device = hass.data[DOMAIN][DOMAIN_DEVICES][udn]
await device.async_stop()
del hass.data[DOMAIN][DOMAIN_DEVICES][udn] LOGGER.debug("Deleting sensors")
_LOGGER.debug("Deleting sensors")
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
class UpnpDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to update data from UPNP device."""
def __init__(
self, hass: HomeAssistant, device: Device, update_interval: timedelta
) -> None:
"""Initialize."""
self.device = device
super().__init__(
hass, LOGGER, name=device.name, update_interval=update_interval
)
async def _async_update_data(self) -> Mapping[str, Any]:
"""Update data."""
update_values = await asyncio.gather(
self.device.async_get_traffic_data(),
self.device.async_get_status(),
)
data = dict(update_values[0])
data.update(update_values[1])
return data
class UpnpEntity(CoordinatorEntity):
"""Base class for UPnP/IGD entities."""
coordinator: UpnpDataUpdateCoordinator
def __init__(self, coordinator: UpnpDataUpdateCoordinator) -> None:
"""Initialize the base entities."""
super().__init__(coordinator)
self._device = coordinator.device
self._attr_device_info = {
"connections": {(dr.CONNECTION_UPNP, coordinator.device.udn)},
"name": coordinator.device.name,
"manufacturer": coordinator.device.manufacturer,
"model": coordinator.device.model_name,
}

View File

@ -0,0 +1,54 @@
"""Support for UPnP/IGD Binary Sensors."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import UpnpDataUpdateCoordinator, UpnpEntity
from .const import DOMAIN, LOGGER, WANSTATUS
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the UPnP/IGD sensors."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
LOGGER.debug("Adding binary sensor")
sensors = [
UpnpStatusBinarySensor(coordinator),
]
async_add_entities(sensors)
class UpnpStatusBinarySensor(UpnpEntity, BinarySensorEntity):
"""Class for UPnP/IGD binary sensors."""
_attr_device_class = DEVICE_CLASS_CONNECTIVITY
def __init__(
self,
coordinator: UpnpDataUpdateCoordinator,
) -> None:
"""Initialize the base sensor."""
super().__init__(coordinator)
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
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.coordinator.data[WANSTATUS] == "Connected"

View File

@ -20,8 +20,7 @@ from .const import (
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
DOMAIN_DEVICES, LOGGER,
LOGGER as _LOGGER,
SSDP_SEARCH_TIMEOUT, SSDP_SEARCH_TIMEOUT,
ST_IGD_V1, ST_IGD_V1,
ST_IGD_V2, ST_IGD_V2,
@ -43,7 +42,7 @@ async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool:
@callback @callback
def device_discovered(info: Mapping[str, Any]) -> None: def device_discovered(info: Mapping[str, Any]) -> None:
_LOGGER.info( LOGGER.info(
"Device discovered: %s, at: %s", "Device discovered: %s, at: %s",
info[ssdp.ATTR_SSDP_USN], info[ssdp.ATTR_SSDP_USN],
info[ssdp.ATTR_SSDP_LOCATION], info[ssdp.ATTR_SSDP_LOCATION],
@ -103,7 +102,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: Mapping | None = None self, user_input: Mapping | None = None
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Handle a flow start.""" """Handle a flow start."""
_LOGGER.debug("async_step_user: user_input: %s", user_input) LOGGER.debug("async_step_user: user_input: %s", user_input)
if user_input is not None: if user_input is not None:
# Ensure wanted device was discovered. # Ensure wanted device was discovered.
@ -162,12 +161,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
configured before, find any device and create a config_entry for it. configured before, find any device and create a config_entry for it.
Otherwise, do nothing. Otherwise, do nothing.
""" """
_LOGGER.debug("async_step_import: import_info: %s", import_info) LOGGER.debug("async_step_import: import_info: %s", import_info)
# Landed here via configuration.yaml entry. # Landed here via configuration.yaml entry.
# Any device already added, then abort. # Any device already added, then abort.
if self._async_current_entries(): if self._async_current_entries():
_LOGGER.debug("Already configured, aborting") LOGGER.debug("Already configured, aborting")
return self.async_abort(reason="already_configured") return self.async_abort(reason="already_configured")
# Discover devices. # Discover devices.
@ -176,7 +175,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
# Ensure anything to add. If not, silently abort. # Ensure anything to add. If not, silently abort.
if not discoveries: if not discoveries:
_LOGGER.info("No UPnP devices discovered, aborting") LOGGER.info("No UPnP devices discovered, aborting")
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
# Ensure complete discovery. # Ensure complete discovery.
@ -187,7 +186,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
or ssdp.ATTR_SSDP_LOCATION not in discovery or ssdp.ATTR_SSDP_LOCATION not in discovery
or ssdp.ATTR_SSDP_USN not in discovery or ssdp.ATTR_SSDP_USN not in discovery
): ):
_LOGGER.debug("Incomplete discovery, ignoring") LOGGER.debug("Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery") return self.async_abort(reason="incomplete_discovery")
# Ensure not already configuring/configured. # Ensure not already configuring/configured.
@ -202,7 +201,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
This flow is triggered by the SSDP component. It will check if the This flow is triggered by the SSDP component. It will check if the
host is already configured and delegate to the import step if not. host is already configured and delegate to the import step if not.
""" """
_LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info) LOGGER.debug("async_step_ssdp: discovery_info: %s", discovery_info)
# Ensure complete discovery. # Ensure complete discovery.
if ( if (
@ -211,7 +210,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
or ssdp.ATTR_SSDP_LOCATION not in discovery_info or ssdp.ATTR_SSDP_LOCATION not in discovery_info
or ssdp.ATTR_SSDP_USN not in discovery_info or ssdp.ATTR_SSDP_USN not in discovery_info
): ):
_LOGGER.debug("Incomplete discovery, ignoring") LOGGER.debug("Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery") return self.async_abort(reason="incomplete_discovery")
# Ensure not already configuring/configured. # Ensure not already configuring/configured.
@ -225,7 +224,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
for config_entry in existing_entries: for config_entry in existing_entries:
entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME) entry_hostname = config_entry.data.get(CONFIG_ENTRY_HOSTNAME)
if entry_hostname == hostname: if entry_hostname == hostname:
_LOGGER.debug( LOGGER.debug(
"Found existing config_entry with same hostname, discovery ignored" "Found existing config_entry with same hostname, discovery ignored"
) )
return self.async_abort(reason="discovery_ignored") return self.async_abort(reason="discovery_ignored")
@ -244,7 +243,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self, user_input: Mapping | None = None self, user_input: Mapping | None = None
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Confirm integration via SSDP.""" """Confirm integration via SSDP."""
_LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input)
if user_input is None: if user_input is None:
return self.async_show_form(step_id="ssdp_confirm") return self.async_show_form(step_id="ssdp_confirm")
@ -264,7 +263,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
discovery: Mapping, discovery: Mapping,
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
"""Create an entry from discovery.""" """Create an entry from discovery."""
_LOGGER.debug( LOGGER.debug(
"_async_create_entry_from_discovery: discovery: %s", "_async_create_entry_from_discovery: discovery: %s",
discovery, discovery,
) )
@ -288,13 +287,12 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow):
async def async_step_init(self, user_input: Mapping = None) -> None: async def async_step_init(self, user_input: Mapping = None) -> None:
"""Manage the options.""" """Manage the options."""
if user_input is not None: if user_input is not None:
udn = self.config_entry.data[CONFIG_ENTRY_UDN] coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id]
coordinator = self.hass.data[DOMAIN][DOMAIN_DEVICES][udn].coordinator
update_interval_sec = user_input.get( update_interval_sec = user_input.get(
CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
) )
update_interval = timedelta(seconds=update_interval_sec) update_interval = timedelta(seconds=update_interval_sec)
_LOGGER.debug("Updating coordinator, update_interval: %s", update_interval) LOGGER.debug("Updating coordinator, update_interval: %s", update_interval)
coordinator.update_interval = update_interval coordinator.update_interval = update_interval
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)

View File

@ -18,6 +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"
WANIP = "wan_ip"
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"

View File

@ -27,6 +27,9 @@ from .const import (
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
TIMESTAMP, TIMESTAMP,
UPTIME,
WANIP,
WANSTATUS,
) )
@ -154,3 +157,18 @@ class Device:
PACKETS_RECEIVED: values[2], PACKETS_RECEIVED: values[2],
PACKETS_SENT: values[3], PACKETS_SENT: values[3],
} }
async def async_get_status(self) -> Mapping[str, Any]:
"""Get connection status, uptime, and external IP."""
_LOGGER.debug("Getting status for device: %s", self)
values = await asyncio.gather(
self._igd_device.async_get_status_info(),
self._igd_device.async_get_external_ip_address(),
)
return {
WANSTATUS: values[0][0] if values[0] is not None else None,
UPTIME: values[0][2] if values[0] is not None else None,
WANIP: values[1],
}

View File

@ -5,7 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/upnp", "documentation": "https://www.home-assistant.io/integrations/upnp",
"requirements": ["async-upnp-client==0.19.2"], "requirements": ["async-upnp-client==0.19.2"],
"dependencies": ["network", "ssdp"], "dependencies": ["network", "ssdp"],
"codeowners": ["@StevenLooman"], "codeowners": ["@StevenLooman","@ehendrix23"],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"

View File

@ -1,38 +1,25 @@
"""Support for UPnP/IGD Sensors.""" """Support for UPnP/IGD Sensors."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import Any, Mapping
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
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import UpnpDataUpdateCoordinator, UpnpEntity
from .const import ( from .const import (
BYTES_RECEIVED, BYTES_RECEIVED,
BYTES_SENT, BYTES_SENT,
CONFIG_ENTRY_SCAN_INTERVAL,
CONFIG_ENTRY_UDN,
DATA_PACKETS, DATA_PACKETS,
DATA_RATE_PACKETS_PER_SECOND, DATA_RATE_PACKETS_PER_SECOND,
DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
DOMAIN_DEVICES,
KIBIBYTE, KIBIBYTE,
LOGGER as _LOGGER, LOGGER,
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
TIMESTAMP, TIMESTAMP,
) )
from .device import Device
SENSOR_TYPES = { SENSOR_TYPES = {
BYTES_RECEIVED: { BYTES_RECEIVED: {
@ -78,7 +65,7 @@ async def async_setup_platform(
hass: HomeAssistant, config, async_add_entities, discovery_info=None hass: HomeAssistant, config, async_add_entities, discovery_info=None
) -> None: ) -> None:
"""Old way of setting up UPnP/IGD sensors.""" """Old way of setting up UPnP/IGD sensors."""
_LOGGER.debug( LOGGER.debug(
"async_setup_platform: config: %s, discovery: %s", config, discovery_info "async_setup_platform: config: %s, discovery: %s", config, discovery_info
) )
@ -89,52 +76,36 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the UPnP/IGD sensors.""" """Set up the UPnP/IGD sensors."""
udn = config_entry.data[CONFIG_ENTRY_UDN] coordinator = hass.data[DOMAIN][config_entry.entry_id]
device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn]
update_interval_sec = config_entry.options.get( LOGGER.debug("Adding sensors")
CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
update_interval = timedelta(seconds=update_interval_sec)
_LOGGER.debug("update_interval: %s", update_interval)
_LOGGER.debug("Adding sensors")
coordinator = DataUpdateCoordinator[Mapping[str, Any]](
hass,
_LOGGER,
name=device.name,
update_method=device.async_get_traffic_data,
update_interval=update_interval,
)
device.coordinator = coordinator
await coordinator.async_refresh()
sensors = [ sensors = [
RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]),
RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), RawUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]),
RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]),
RawUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), RawUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]),
DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_RECEIVED]),
DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_SENT]), DerivedUpnpSensor(coordinator, SENSOR_TYPES[BYTES_SENT]),
DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_RECEIVED]), DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_RECEIVED]),
DerivedUpnpSensor(coordinator, device, SENSOR_TYPES[PACKETS_SENT]), DerivedUpnpSensor(coordinator, SENSOR_TYPES[PACKETS_SENT]),
] ]
async_add_entities(sensors, True) async_add_entities(sensors)
class UpnpSensor(CoordinatorEntity, SensorEntity): class UpnpSensor(UpnpEntity, SensorEntity):
"""Base class for UPnP/IGD sensors.""" """Base class for UPnP/IGD sensors."""
def __init__( def __init__(
self, self,
coordinator: DataUpdateCoordinator[Mapping[str, Any]], coordinator: UpnpDataUpdateCoordinator,
device: Device, sensor_type: dict[str, str],
sensor_type: Mapping[str, str],
) -> None: ) -> None:
"""Initialize the base sensor.""" """Initialize the base sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self._device = device
self._sensor_type = sensor_type 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 @property
def icon(self) -> str: def icon(self) -> str:
@ -144,37 +115,15 @@ class UpnpSensor(CoordinatorEntity, SensorEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
device_value_key = self._sensor_type["device_value_key"] return super().available and self.coordinator.data.get(
return ( self._sensor_type["device_value_key"]
self.coordinator.last_update_success
and device_value_key in self.coordinator.data
) )
@property
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._device.name} {self._sensor_type['name']}"
@property
def unique_id(self) -> str:
"""Return an unique ID."""
return f"{self._device.udn}_{self._sensor_type['unique_id']}"
@property @property
def native_unit_of_measurement(self) -> str: def native_unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
return self._sensor_type["unit"] return self._sensor_type["unit"]
@property
def device_info(self) -> DeviceInfo:
"""Get device info."""
return {
"connections": {(dr.CONNECTION_UPNP, self._device.udn)},
"name": self._device.name,
"manufacturer": self._device.manufacturer,
"model": self._device.model_name,
}
class RawUpnpSensor(UpnpSensor): class RawUpnpSensor(UpnpSensor):
"""Representation of a UPnP/IGD sensor.""" """Representation of a UPnP/IGD sensor."""
@ -192,21 +141,15 @@ class RawUpnpSensor(UpnpSensor):
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, device, sensor_type) -> None: def __init__(self, coordinator: UpnpDataUpdateCoordinator, sensor_type) -> None:
"""Initialize sensor.""" """Initialize sensor."""
super().__init__(coordinator, device, sensor_type) super().__init__(coordinator, sensor_type)
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']}"
@property self._attr_unique_id = (
def name(self) -> str: f"{coordinator.device.udn}_{sensor_type['derived_unique_id']}"
"""Return the name of the sensor.""" )
return f"{self._device.name} {self._sensor_type['derived_name']}"
@property
def unique_id(self) -> str:
"""Return an unique ID."""
return f"{self._device.udn}_{self._sensor_type['derived_unique_id']}"
@property @property
def native_unit_of_measurement(self) -> str: def native_unit_of_measurement(self) -> str:

View File

@ -11,6 +11,9 @@ from homeassistant.components.upnp.const import (
PACKETS_RECEIVED, PACKETS_RECEIVED,
PACKETS_SENT, PACKETS_SENT,
TIMESTAMP, TIMESTAMP,
UPTIME,
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
@ -27,7 +30,8 @@ class MockDevice(Device):
mock_device_updater = AsyncMock() mock_device_updater = AsyncMock()
super().__init__(igd_device, mock_device_updater) super().__init__(igd_device, mock_device_updater)
self._udn = udn self._udn = udn
self.times_polled = 0 self.traffic_times_polled = 0
self.status_times_polled = 0
@classmethod @classmethod
async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": async def async_create_device(cls, hass, ssdp_location) -> "MockDevice":
@ -66,7 +70,7 @@ class MockDevice(Device):
async def async_get_traffic_data(self) -> Mapping[str, Any]: async def async_get_traffic_data(self) -> Mapping[str, Any]:
"""Get traffic data.""" """Get traffic data."""
self.times_polled += 1 self.traffic_times_polled += 1
return { return {
TIMESTAMP: dt.utcnow(), TIMESTAMP: dt.utcnow(),
BYTES_RECEIVED: 0, BYTES_RECEIVED: 0,
@ -75,6 +79,15 @@ class MockDevice(Device):
PACKETS_SENT: 0, PACKETS_SENT: 0,
} }
async def async_get_status(self) -> Mapping[str, Any]:
"""Get connection status, uptime, and external IP."""
self.status_times_polled += 1
return {
WANSTATUS: "Connected",
UPTIME: 0,
WANIP: "192.168.0.1",
}
async def async_start(self) -> None: async def async_start(self) -> None:
"""Start the device updater.""" """Start the device updater."""

View File

@ -14,7 +14,6 @@ from homeassistant.components.upnp.const import (
CONFIG_ENTRY_UDN, CONFIG_ENTRY_UDN,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
DOMAIN_DEVICES,
) )
from homeassistant.core import CoreState, HomeAssistant from homeassistant.core import CoreState, HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -238,15 +237,17 @@ async def test_options_flow(hass: HomeAssistant):
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id) is True assert await hass.config_entries.async_setup(config_entry.entry_id) is True
await hass.async_block_till_done() await hass.async_block_till_done()
mock_device = hass.data[DOMAIN][DOMAIN_DEVICES][TEST_UDN] mock_device = hass.data[DOMAIN][config_entry.entry_id].device
# Reset. # Reset.
mock_device.times_polled = 0 mock_device.traffic_times_polled = 0
mock_device.status_times_polled = 0
# Forward time, ensure single poll after 30 (default) seconds. # Forward time, ensure single poll after 30 (default) seconds.
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31))
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_device.times_polled == 1 assert mock_device.traffic_times_polled == 1
assert mock_device.status_times_polled == 1
# Options flow with no input results in form. # Options flow with no input results in form.
result = await hass.config_entries.options.async_init( result = await hass.config_entries.options.async_init(
@ -267,15 +268,18 @@ async def test_options_flow(hass: HomeAssistant):
# Forward time, ensure single poll after 60 seconds, still from original setting. # Forward time, ensure single poll after 60 seconds, still from original setting.
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61))
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_device.times_polled == 2 assert mock_device.traffic_times_polled == 2
assert mock_device.status_times_polled == 2
# Now the updated interval takes effect. # Now the updated interval takes effect.
# Forward time, ensure single poll after 120 seconds. # Forward time, ensure single poll after 120 seconds.
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121)) async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=121))
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_device.times_polled == 3 assert mock_device.traffic_times_polled == 3
assert mock_device.status_times_polled == 3
# Forward time, ensure single poll after 180 seconds. # Forward time, ensure single poll after 180 seconds.
async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181)) async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=181))
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_device.times_polled == 4 assert mock_device.traffic_times_polled == 4
assert mock_device.status_times_polled == 4