core/homeassistant/components/husqvarna_automower/sensor.py

538 lines
19 KiB
Python

"""Creates the sensor entities for the mower."""
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
import logging
from operator import attrgetter
from typing import TYPE_CHECKING, Any
from aioautomower.model import (
MowerAttributes,
MowerModes,
MowerStates,
RestrictedReasons,
WorkArea,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import (
AutomowerBaseEntity,
WorkAreaAvailableEntity,
_work_area_translation_key,
)
_LOGGER = logging.getLogger(__name__)
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
ERROR_KEY_LIST = [
"no_error",
"alarm_mower_in_motion",
"alarm_mower_lifted",
"alarm_mower_stopped",
"alarm_mower_switched_off",
"alarm_mower_tilted",
"alarm_outside_geofence",
"angular_sensor_problem",
"battery_problem",
"battery_problem",
"battery_restriction_due_to_ambient_temperature",
"can_error",
"charging_current_too_high",
"charging_station_blocked",
"charging_system_problem",
"charging_system_problem",
"collision_sensor_defect",
"collision_sensor_error",
"collision_sensor_problem_front",
"collision_sensor_problem_rear",
"com_board_not_available",
"communication_circuit_board_sw_must_be_updated",
"complex_working_area",
"connection_changed",
"connection_not_changed",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_settings_restored",
"cutting_drive_motor_1_defect",
"cutting_drive_motor_2_defect",
"cutting_drive_motor_3_defect",
"cutting_height_blocked",
"cutting_height_problem",
"cutting_height_problem_curr",
"cutting_height_problem_dir",
"cutting_height_problem_drive",
"cutting_motor_problem",
"cutting_stopped_slope_too_steep",
"cutting_system_blocked",
"cutting_system_blocked",
"cutting_system_imbalance_warning",
"cutting_system_major_imbalance",
"destination_not_reachable",
"difficult_finding_home",
"docking_sensor_defect",
"electronic_problem",
"empty_battery",
MowerStates.ERROR.lower(),
MowerStates.ERROR_AT_POWER_UP.lower(),
MowerStates.FATAL_ERROR.lower(),
"folding_cutting_deck_sensor_defect",
"folding_sensor_activated",
"geofence_problem",
"geofence_problem",
"gps_navigation_problem",
"guide_1_not_found",
"guide_2_not_found",
"guide_3_not_found",
"guide_calibration_accomplished",
"guide_calibration_failed",
"high_charging_power_loss",
"high_internal_power_loss",
"high_internal_temperature",
"internal_voltage_error",
"invalid_battery_combination_invalid_combination_of_different_battery_types",
"invalid_sub_device_combination",
"invalid_system_configuration",
"left_brush_motor_overloaded",
"lift_sensor_defect",
"lifted",
"limited_cutting_height_range",
"limited_cutting_height_range",
"loop_sensor_defect",
"loop_sensor_problem_front",
"loop_sensor_problem_left",
"loop_sensor_problem_rear",
"loop_sensor_problem_right",
"low_battery",
"memory_circuit_problem",
"mower_lifted",
"mower_tilted",
"no_accurate_position_from_satellites",
"no_confirmed_position",
"no_drive",
"no_loop_signal",
"no_power_in_charging_station",
"no_response_from_charger",
"outside_working_area",
"poor_signal_quality",
"reference_station_communication_problem",
"right_brush_motor_overloaded",
"safety_function_faulty",
"settings_restored",
"sim_card_locked",
"sim_card_locked",
"sim_card_locked",
"sim_card_locked",
"sim_card_not_found",
"sim_card_requires_pin",
"slipped_mower_has_slipped_situation_not_solved_with_moving_pattern",
"slope_too_steep",
"sms_could_not_be_sent",
"stop_button_problem",
"stuck_in_charging_station",
"switch_cord_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"tilt_sensor_problem",
"too_high_discharge_current",
"too_high_internal_current",
"trapped",
"ultrasonic_problem",
"ultrasonic_sensor_1_defect",
"ultrasonic_sensor_2_defect",
"ultrasonic_sensor_3_defect",
"ultrasonic_sensor_4_defect",
"unexpected_cutting_height_adj",
"unexpected_error",
"upside_down",
"weak_gps_signal",
"wheel_drive_problem_left",
"wheel_drive_problem_rear_left",
"wheel_drive_problem_rear_right",
"wheel_drive_problem_right",
"wheel_motor_blocked_left",
"wheel_motor_blocked_rear_left",
"wheel_motor_blocked_rear_right",
"wheel_motor_blocked_right",
"wheel_motor_overloaded_left",
"wheel_motor_overloaded_rear_left",
"wheel_motor_overloaded_rear_right",
"wheel_motor_overloaded_right",
"work_area_not_valid",
"wrong_loop_signal",
"wrong_pin_code",
"zone_generator_problem",
]
ERROR_STATES = {
MowerStates.ERROR,
MowerStates.ERROR_AT_POWER_UP,
MowerStates.FATAL_ERROR,
}
RESTRICTED_REASONS: list = [
RestrictedReasons.ALL_WORK_AREAS_COMPLETED,
RestrictedReasons.DAILY_LIMIT,
RestrictedReasons.EXTERNAL,
RestrictedReasons.FOTA,
RestrictedReasons.FROST,
RestrictedReasons.NONE,
RestrictedReasons.NOT_APPLICABLE,
RestrictedReasons.PARK_OVERRIDE,
RestrictedReasons.SENSOR,
RestrictedReasons.WEEK_SCHEDULE,
]
STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active"
@callback
def _get_work_area_names(data: MowerAttributes) -> list[str]:
"""Return a list with all work area names."""
if TYPE_CHECKING:
# Sensor does not get created if it is None
assert data.work_areas is not None
work_area_list = [
data.work_areas[work_area_id].name for work_area_id in data.work_areas
]
work_area_list.append(STATE_NO_WORK_AREA_ACTIVE)
return work_area_list
@callback
def _get_current_work_area_name(data: MowerAttributes) -> str:
"""Return the name of the current work area."""
if data.mower.work_area_id is None:
return STATE_NO_WORK_AREA_ACTIVE
if TYPE_CHECKING:
# Sensor does not get created if values are None
assert data.work_areas is not None
return data.work_areas[data.mower.work_area_id].name
@callback
def _get_current_work_area_dict(data: MowerAttributes) -> Mapping[str, Any]:
"""Return the name of the current work area."""
if TYPE_CHECKING:
# Sensor does not get created if it is None
assert data.work_areas is not None
return {ATTR_WORK_AREA_ID_ASSIGNMENT: data.work_area_dict}
@callback
def _get_error_string(data: MowerAttributes) -> str:
"""Return the error key, if not provided the mower state or `no error`."""
if data.mower.error_key is not None:
return data.mower.error_key
if data.mower.state in ERROR_STATES:
return data.mower.state.lower()
return "no_error"
@dataclass(frozen=True, kw_only=True)
class AutomowerSensorEntityDescription(SensorEntityDescription):
"""Describes Automower sensor entity."""
exists_fn: Callable[[MowerAttributes], bool] = lambda _: True
extra_state_attributes_fn: Callable[[MowerAttributes], Mapping[str, Any] | None] = (
lambda _: None
)
option_fn: Callable[[MowerAttributes], list[str] | None] = lambda _: None
value_fn: Callable[[MowerAttributes], StateType | datetime]
MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="battery_percent",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
value_fn=attrgetter("battery.battery_percent"),
),
AutomowerSensorEntityDescription(
key="mode",
translation_key="mode",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: list(MowerModes),
value_fn=(
lambda data: data.mower.mode
if data.mower.mode != MowerModes.UNKNOWN
else None
),
),
AutomowerSensorEntityDescription(
key="cutting_blade_usage_time",
translation_key="cutting_blade_usage_time",
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None,
value_fn=attrgetter("statistics.cutting_blade_usage_time"),
),
AutomowerSensorEntityDescription(
key="total_charging_time",
translation_key="total_charging_time",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
exists_fn=lambda data: data.statistics.total_charging_time is not None,
value_fn=attrgetter("statistics.total_charging_time"),
),
AutomowerSensorEntityDescription(
key="total_cutting_time",
translation_key="total_cutting_time",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
exists_fn=lambda data: data.statistics.total_cutting_time is not None,
value_fn=attrgetter("statistics.total_cutting_time"),
),
AutomowerSensorEntityDescription(
key="total_running_time",
translation_key="total_running_time",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
exists_fn=lambda data: data.statistics.total_running_time is not None,
value_fn=attrgetter("statistics.total_running_time"),
),
AutomowerSensorEntityDescription(
key="total_searching_time",
translation_key="total_searching_time",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
exists_fn=lambda data: data.statistics.total_searching_time is not None,
value_fn=attrgetter("statistics.total_searching_time"),
),
AutomowerSensorEntityDescription(
key="number_of_charging_cycles",
translation_key="number_of_charging_cycles",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
exists_fn=lambda data: data.statistics.number_of_charging_cycles is not None,
value_fn=attrgetter("statistics.number_of_charging_cycles"),
),
AutomowerSensorEntityDescription(
key="number_of_collisions",
translation_key="number_of_collisions",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
exists_fn=lambda data: data.statistics.number_of_collisions is not None,
value_fn=attrgetter("statistics.number_of_collisions"),
),
AutomowerSensorEntityDescription(
key="total_drive_distance",
translation_key="total_drive_distance",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.METERS,
suggested_unit_of_measurement=UnitOfLength.KILOMETERS,
exists_fn=lambda data: data.statistics.total_drive_distance is not None,
value_fn=attrgetter("statistics.total_drive_distance"),
),
AutomowerSensorEntityDescription(
key="next_start_timestamp",
translation_key="next_start_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=attrgetter("planner.next_start_datetime"),
),
AutomowerSensorEntityDescription(
key="error",
translation_key="error",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: ERROR_KEY_LIST,
value_fn=_get_error_string,
),
AutomowerSensorEntityDescription(
key="restricted_reason",
translation_key="restricted_reason",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: RESTRICTED_REASONS,
value_fn=attrgetter("planner.restricted_reason"),
),
AutomowerSensorEntityDescription(
key="work_area",
translation_key="work_area",
device_class=SensorDeviceClass.ENUM,
exists_fn=lambda data: data.capabilities.work_areas,
extra_state_attributes_fn=_get_current_work_area_dict,
option_fn=_get_work_area_names,
value_fn=_get_current_work_area_name,
),
)
@dataclass(frozen=True, kw_only=True)
class WorkAreaSensorEntityDescription(SensorEntityDescription):
"""Describes the work area sensor entities."""
exists_fn: Callable[[WorkArea], bool] = lambda _: True
value_fn: Callable[[WorkArea], StateType | datetime]
translation_key_fn: Callable[[int, str], str]
WORK_AREA_SENSOR_TYPES: tuple[WorkAreaSensorEntityDescription, ...] = (
WorkAreaSensorEntityDescription(
key="progress",
translation_key_fn=_work_area_translation_key,
exists_fn=lambda data: data.progress is not None,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=attrgetter("progress"),
),
WorkAreaSensorEntityDescription(
key="last_time_completed",
translation_key_fn=_work_area_translation_key,
exists_fn=lambda data: data.last_time_completed is not None,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=attrgetter("last_time_completed"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor platform."""
coordinator = entry.runtime_data
current_work_areas: dict[str, set[int]] = {}
async_add_entities(
AutomowerSensorEntity(mower_id, coordinator, description)
for mower_id, data in coordinator.data.items()
for description in MOWER_SENSOR_TYPES
if description.exists_fn(data)
)
def _async_work_area_listener() -> None:
"""Listen for new work areas and add sensor entities if they did not exist.
Listening for deletable work areas is managed in the number platform.
"""
for mower_id in coordinator.data:
if (
coordinator.data[mower_id].capabilities.work_areas
and (_work_areas := coordinator.data[mower_id].work_areas) is not None
):
received_work_areas = set(_work_areas.keys())
new_work_areas = received_work_areas - current_work_areas.get(
mower_id, set()
)
if new_work_areas:
current_work_areas.setdefault(mower_id, set()).update(
new_work_areas
)
async_add_entities(
WorkAreaSensorEntity(
mower_id, coordinator, description, work_area_id
)
for description in WORK_AREA_SENSOR_TYPES
for work_area_id in new_work_areas
if description.exists_fn(_work_areas[work_area_id])
)
coordinator.async_add_listener(_async_work_area_listener)
_async_work_area_listener()
class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity):
"""Defining the Automower Sensors with AutomowerSensorEntityDescription."""
entity_description: AutomowerSensorEntityDescription
_unrecorded_attributes = frozenset({ATTR_WORK_AREA_ID_ASSIGNMENT})
def __init__(
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
description: AutomowerSensorEntityDescription,
) -> None:
"""Set up AutomowerSensors."""
super().__init__(mower_id, coordinator)
self.entity_description = description
self._attr_unique_id = f"{mower_id}_{description.key}"
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.mower_attributes)
@property
def options(self) -> list[str] | None:
"""Return the option of the sensor."""
return self.entity_description.option_fn(self.mower_attributes)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
return self.entity_description.extra_state_attributes_fn(self.mower_attributes)
class WorkAreaSensorEntity(WorkAreaAvailableEntity, SensorEntity):
"""Defining the Work area sensors with WorkAreaSensorEntityDescription."""
entity_description: WorkAreaSensorEntityDescription
def __init__(
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
description: WorkAreaSensorEntityDescription,
work_area_id: int,
) -> None:
"""Set up AutomowerSensors."""
super().__init__(mower_id, coordinator, work_area_id)
self.entity_description = description
self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}"
self._attr_translation_placeholders = {
"work_area": self.work_area_attributes.name
}
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.work_area_attributes)
@property
def translation_key(self) -> str:
"""Return the translation key of the work area."""
return self.entity_description.translation_key_fn(
self.work_area_id, self.entity_description.key
)