1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00
ha-core/homeassistant/components/device_tracker/config_entry.py
Erik Montnemery 30fd902764
Correct zone state (#65040)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2022-01-27 10:01:09 -08:00

360 lines
11 KiB
Python

"""Code to set up a device tracker platform using a config entry."""
from __future__ import annotations
import asyncio
from typing import final
from homeassistant.components import zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import StateType
from .const import ATTR_HOST_NAME, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, DOMAIN, LOGGER
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an entry."""
component: EntityComponent | None = hass.data.get(DOMAIN)
if component is not None:
return await component.async_setup_entry(entry)
component = hass.data[DOMAIN] = EntityComponent(LOGGER, DOMAIN, hass)
# Clean up old devices created by device tracker entities in the past.
# Can be removed after 2022.6
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
devices_with_trackers = set()
devices_with_non_trackers = set()
for entity in ent_reg.entities.values():
if entity.device_id is None:
continue
if entity.domain == DOMAIN:
devices_with_trackers.add(entity.device_id)
else:
devices_with_non_trackers.add(entity.device_id)
for device_id in devices_with_trackers - devices_with_non_trackers:
for entity in er.async_entries_for_device(ent_reg, device_id, True):
ent_reg.async_update_entity(entity.entity_id, device_id=None)
dev_reg.async_remove_device(device_id)
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an entry."""
component: EntityComponent = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
@callback
def _async_register_mac(
hass: HomeAssistant, domain: str, mac: str, unique_id: str
) -> None:
"""Register a mac address with a unique ID."""
data_key = "device_tracker_mac"
mac = dr.format_mac(mac)
if data_key in hass.data:
hass.data[data_key][mac] = (domain, unique_id)
return
# Setup listening.
# dict mapping mac -> partial unique ID
data = hass.data[data_key] = {mac: (domain, unique_id)}
@callback
def handle_device_event(ev: Event) -> None:
"""Enable the online status entity for the mac of a newly created device."""
# Only for new devices
if ev.data["action"] != "create":
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
if device_entry is None:
return
# Check if device has a mac
mac = None
for conn in device_entry.connections:
if conn[0] == dr.CONNECTION_NETWORK_MAC:
mac = conn[1]
break
if mac is None:
return
# Check if we have an entity for this mac
if (unique_id := data.get(mac)) is None:
return
ent_reg = er.async_get(hass)
entity_id = ent_reg.async_get_entity_id(DOMAIN, *unique_id)
if entity_id is None:
return
entity_entry = ent_reg.async_get(entity_id)
if entity_entry is None:
return
# Make sure entity has a config entry and was disabled by the
# default disable logic in the integration.
if (
entity_entry.config_entry_id is None
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
):
return
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
class BaseTrackerEntity(Entity):
"""Represent a tracked device."""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
@property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return None
@property
def source_type(self) -> str:
"""Return the source type, eg gps or router, of the device."""
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntity(BaseTrackerEntity):
"""Base class for a tracked device."""
@property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
return False
@property
def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@property
def location_accuracy(self) -> int:
"""Return the location accuracy of the device.
Value in meters.
"""
return 0
@property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
return None
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
raise NotImplementedError
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
raise NotImplementedError
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.location_name is not None:
return self.location_name
if self.latitude is not None and self.longitude is not None:
zone_state = zone.async_active_zone(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
state = STATE_HOME
else:
state = zone_state.name
return state
return None
@final
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
return attr
class ScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that is on a scanned network."""
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return None
@property
def mac_address(self) -> str | None:
"""Return the mac address of the device."""
return None
@property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return None
@property
def state(self) -> str:
"""Return the state of the device."""
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
raise NotImplementedError
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return self.mac_address
@final
@property
def device_info(self) -> DeviceInfo | None:
"""Device tracker entities should not create device registry entries."""
return None
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
return (
# Do not disable if we won't activate our attach to device logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.mac_address and self.unique_id:
_async_register_mac(
hass, platform.platform_name, self.mac_address, self.unique_id
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
set(), {(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
async def async_internal_added_to_hass(self) -> None:
"""Handle added to Home Assistant."""
# Entities without a unique ID don't have a device
if (
not self.registry_entry
or not self.platform
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
):
if self.device_info:
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
await super().async_internal_added_to_hass()
return
# Attach entry to device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
# Attach device to config entry
if self.platform.config_entry.entry_id not in device_entry.config_entries:
dr.async_get(self.hass).async_update_device(
device_entry.id,
add_config_entry_id=self.platform.config_entry.entry_id,
)
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
@final
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {}
attr.update(super().state_attributes)
if self.ip_address is not None:
attr[ATTR_IP] = self.ip_address
if self.mac_address is not None:
attr[ATTR_MAC] = self.mac_address
if self.hostname is not None:
attr[ATTR_HOST_NAME] = self.hostname
return attr