274 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			274 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Python
		
	
	
"""Support for August binary sensors."""
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
from collections.abc import Callable
 | 
						|
from dataclasses import dataclass
 | 
						|
from datetime import datetime, timedelta
 | 
						|
import logging
 | 
						|
from typing import cast
 | 
						|
 | 
						|
from yalexs.activity import (
 | 
						|
    ACTION_DOORBELL_CALL_MISSED,
 | 
						|
    SOURCE_PUBNUB,
 | 
						|
    Activity,
 | 
						|
    ActivityType,
 | 
						|
)
 | 
						|
from yalexs.doorbell import DoorbellDetail
 | 
						|
from yalexs.lock import LockDoorStatus
 | 
						|
from yalexs.util import update_lock_detail_from_activity
 | 
						|
 | 
						|
from homeassistant.components.august import AugustData
 | 
						|
from homeassistant.components.binary_sensor import (
 | 
						|
    DEVICE_CLASS_CONNECTIVITY,
 | 
						|
    DEVICE_CLASS_DOOR,
 | 
						|
    DEVICE_CLASS_MOTION,
 | 
						|
    DEVICE_CLASS_OCCUPANCY,
 | 
						|
    BinarySensorEntity,
 | 
						|
    BinarySensorEntityDescription,
 | 
						|
)
 | 
						|
from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC
 | 
						|
from homeassistant.core import callback
 | 
						|
from homeassistant.helpers.event import async_call_later
 | 
						|
 | 
						|
from .const import ACTIVITY_UPDATE_INTERVAL, DATA_AUGUST, DOMAIN
 | 
						|
from .entity import AugustEntityMixin
 | 
						|
 | 
						|
_LOGGER = logging.getLogger(__name__)
 | 
						|
 | 
						|
TIME_TO_DECLARE_DETECTION = timedelta(seconds=ACTIVITY_UPDATE_INTERVAL.total_seconds())
 | 
						|
TIME_TO_RECHECK_DETECTION = timedelta(
 | 
						|
    seconds=ACTIVITY_UPDATE_INTERVAL.total_seconds() * 3
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool:
 | 
						|
    """Get the latest state of the sensor."""
 | 
						|
    # The doorbell will go into standby mode when there is no motion
 | 
						|
    # for a short while. It will wake by itself when needed so we need
 | 
						|
    # to consider is available or we will not report motion or dings
 | 
						|
 | 
						|
    return detail.is_online or detail.is_standby
 | 
						|
 | 
						|
 | 
						|
def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool:
 | 
						|
    latest = data.activity_stream.get_latest_device_activity(
 | 
						|
        detail.device_id, {ActivityType.DOORBELL_MOTION}
 | 
						|
    )
 | 
						|
 | 
						|
    if latest is None:
 | 
						|
        return False
 | 
						|
 | 
						|
    return _activity_time_based_state(latest)
 | 
						|
 | 
						|
 | 
						|
def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool:
 | 
						|
    latest = data.activity_stream.get_latest_device_activity(
 | 
						|
        detail.device_id, {ActivityType.DOORBELL_DING}
 | 
						|
    )
 | 
						|
 | 
						|
    if latest is None:
 | 
						|
        return False
 | 
						|
 | 
						|
    if (
 | 
						|
        data.activity_stream.pubnub.connected
 | 
						|
        and latest.action == ACTION_DOORBELL_CALL_MISSED
 | 
						|
    ):
 | 
						|
        return False
 | 
						|
 | 
						|
    return _activity_time_based_state(latest)
 | 
						|
 | 
						|
 | 
						|
def _activity_time_based_state(latest: Activity) -> bool:
 | 
						|
    """Get the latest state of the sensor."""
 | 
						|
    start = latest.activity_start_time
 | 
						|
    end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION
 | 
						|
    return start <= _native_datetime() <= end
 | 
						|
 | 
						|
 | 
						|
def _native_datetime() -> datetime:
 | 
						|
    """Return time in the format august uses without timezone."""
 | 
						|
    return datetime.now()
 | 
						|
 | 
						|
 | 
						|
@dataclass
 | 
						|
class AugustRequiredKeysMixin:
 | 
						|
    """Mixin for required keys."""
 | 
						|
 | 
						|
    value_fn: Callable[[AugustData, DoorbellDetail], bool]
 | 
						|
    is_time_based: bool
 | 
						|
 | 
						|
 | 
						|
@dataclass
 | 
						|
class AugustBinarySensorEntityDescription(
 | 
						|
    BinarySensorEntityDescription, AugustRequiredKeysMixin
 | 
						|
):
 | 
						|
    """Describes August binary_sensor entity."""
 | 
						|
 | 
						|
 | 
						|
SENSOR_TYPE_DOOR = BinarySensorEntityDescription(
 | 
						|
    key="door_open",
 | 
						|
    name="Open",
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
SENSOR_TYPES_DOORBELL: tuple[AugustBinarySensorEntityDescription, ...] = (
 | 
						|
    AugustBinarySensorEntityDescription(
 | 
						|
        key="doorbell_ding",
 | 
						|
        name="Ding",
 | 
						|
        device_class=DEVICE_CLASS_OCCUPANCY,
 | 
						|
        value_fn=_retrieve_ding_state,
 | 
						|
        is_time_based=True,
 | 
						|
    ),
 | 
						|
    AugustBinarySensorEntityDescription(
 | 
						|
        key="doorbell_motion",
 | 
						|
        name="Motion",
 | 
						|
        device_class=DEVICE_CLASS_MOTION,
 | 
						|
        value_fn=_retrieve_motion_state,
 | 
						|
        is_time_based=True,
 | 
						|
    ),
 | 
						|
    AugustBinarySensorEntityDescription(
 | 
						|
        key="doorbell_online",
 | 
						|
        name="Online",
 | 
						|
        device_class=DEVICE_CLASS_CONNECTIVITY,
 | 
						|
        entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
 | 
						|
        value_fn=_retrieve_online_state,
 | 
						|
        is_time_based=False,
 | 
						|
    ),
 | 
						|
)
 | 
						|
 | 
						|
 | 
						|
async def async_setup_entry(hass, config_entry, async_add_entities):
 | 
						|
    """Set up the August binary sensors."""
 | 
						|
    data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST]
 | 
						|
    entities = []
 | 
						|
 | 
						|
    for door in data.locks:
 | 
						|
        detail = data.get_device_detail(door.device_id)
 | 
						|
        if not detail.doorsense:
 | 
						|
            _LOGGER.debug(
 | 
						|
                "Not adding sensor class door for lock %s because it does not have doorsense",
 | 
						|
                door.device_name,
 | 
						|
            )
 | 
						|
            continue
 | 
						|
 | 
						|
        _LOGGER.debug("Adding sensor class door for %s", door.device_name)
 | 
						|
        entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR))
 | 
						|
 | 
						|
    for doorbell in data.doorbells:
 | 
						|
        for description in SENSOR_TYPES_DOORBELL:
 | 
						|
            _LOGGER.debug(
 | 
						|
                "Adding doorbell sensor class %s for %s",
 | 
						|
                description.device_class,
 | 
						|
                doorbell.device_name,
 | 
						|
            )
 | 
						|
            entities.append(AugustDoorbellBinarySensor(data, doorbell, description))
 | 
						|
 | 
						|
    async_add_entities(entities)
 | 
						|
 | 
						|
 | 
						|
class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity):
 | 
						|
    """Representation of an August Door binary sensor."""
 | 
						|
 | 
						|
    _attr_device_class = DEVICE_CLASS_DOOR
 | 
						|
 | 
						|
    def __init__(self, data, device, description: BinarySensorEntityDescription):
 | 
						|
        """Initialize the sensor."""
 | 
						|
        super().__init__(data, device)
 | 
						|
        self.entity_description = description
 | 
						|
        self._data = data
 | 
						|
        self._device = device
 | 
						|
        self._attr_name = f"{device.device_name} {description.name}"
 | 
						|
        self._attr_unique_id = (
 | 
						|
            f"{self._device_id}_{cast(str, description.name).lower()}"
 | 
						|
        )
 | 
						|
        self._update_from_data()
 | 
						|
 | 
						|
    @callback
 | 
						|
    def _update_from_data(self):
 | 
						|
        """Get the latest state of the sensor and update activity."""
 | 
						|
        door_activity = self._data.activity_stream.get_latest_device_activity(
 | 
						|
            self._device_id, {ActivityType.DOOR_OPERATION}
 | 
						|
        )
 | 
						|
 | 
						|
        if door_activity is not None:
 | 
						|
            update_lock_detail_from_activity(self._detail, door_activity)
 | 
						|
            # If the source is pubnub the lock must be online since its a live update
 | 
						|
            if door_activity.source == SOURCE_PUBNUB:
 | 
						|
                self._detail.set_online(True)
 | 
						|
 | 
						|
        bridge_activity = self._data.activity_stream.get_latest_device_activity(
 | 
						|
            self._device_id, {ActivityType.BRIDGE_OPERATION}
 | 
						|
        )
 | 
						|
 | 
						|
        if bridge_activity is not None:
 | 
						|
            update_lock_detail_from_activity(self._detail, bridge_activity)
 | 
						|
        self._attr_available = self._detail.bridge_is_online
 | 
						|
        self._attr_is_on = self._detail.door_state == LockDoorStatus.OPEN
 | 
						|
 | 
						|
 | 
						|
class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity):
 | 
						|
    """Representation of an August binary sensor."""
 | 
						|
 | 
						|
    entity_description: AugustBinarySensorEntityDescription
 | 
						|
 | 
						|
    def __init__(self, data, device, description: AugustBinarySensorEntityDescription):
 | 
						|
        """Initialize the sensor."""
 | 
						|
        super().__init__(data, device)
 | 
						|
        self.entity_description = description
 | 
						|
        self._check_for_off_update_listener = None
 | 
						|
        self._data = data
 | 
						|
        self._attr_name = f"{device.device_name} {description.name}"
 | 
						|
        self._attr_unique_id = (
 | 
						|
            f"{self._device_id}_{cast(str, description.name).lower()}"
 | 
						|
        )
 | 
						|
        self._update_from_data()
 | 
						|
 | 
						|
    @callback
 | 
						|
    def _update_from_data(self):
 | 
						|
        """Get the latest state of the sensor."""
 | 
						|
        self._cancel_any_pending_updates()
 | 
						|
        self._attr_is_on = self.entity_description.value_fn(self._data, self._detail)
 | 
						|
 | 
						|
        if self.entity_description.is_time_based:
 | 
						|
            self._attr_available = _retrieve_online_state(self._data, self._detail)
 | 
						|
            self._schedule_update_to_recheck_turn_off_sensor()
 | 
						|
        else:
 | 
						|
            self._attr_available = True
 | 
						|
 | 
						|
    def _schedule_update_to_recheck_turn_off_sensor(self):
 | 
						|
        """Schedule an update to recheck the sensor to see if it is ready to turn off."""
 | 
						|
 | 
						|
        # If the sensor is already off there is nothing to do
 | 
						|
        if not self.is_on:
 | 
						|
            return
 | 
						|
 | 
						|
        # self.hass is only available after setup is completed
 | 
						|
        # and we will recheck in async_added_to_hass
 | 
						|
        if not self.hass:
 | 
						|
            return
 | 
						|
 | 
						|
        @callback
 | 
						|
        def _scheduled_update(now):
 | 
						|
            """Timer callback for sensor update."""
 | 
						|
            self._check_for_off_update_listener = None
 | 
						|
            self._update_from_data()
 | 
						|
            if not self.is_on:
 | 
						|
                self.async_write_ha_state()
 | 
						|
 | 
						|
        self._check_for_off_update_listener = async_call_later(
 | 
						|
            self.hass, TIME_TO_RECHECK_DETECTION.total_seconds(), _scheduled_update
 | 
						|
        )
 | 
						|
 | 
						|
    def _cancel_any_pending_updates(self):
 | 
						|
        """Cancel any updates to recheck a sensor to see if it is ready to turn off."""
 | 
						|
        if not self._check_for_off_update_listener:
 | 
						|
            return
 | 
						|
        _LOGGER.debug("%s: canceled pending update", self.entity_id)
 | 
						|
        self._check_for_off_update_listener()
 | 
						|
        self._check_for_off_update_listener = None
 | 
						|
 | 
						|
    async def async_added_to_hass(self):
 | 
						|
        """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed."""
 | 
						|
        self._schedule_update_to_recheck_turn_off_sensor()
 | 
						|
        await super().async_added_to_hass()
 |