core/homeassistant/components/husqvarna_automower/sensor.py

423 lines
15 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 typing import TYPE_CHECKING, Any
from zoneinfo import ZoneInfo
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons
from aioautomower.utils import naive_to_aware
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 homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity
_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",
"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",
]
RESTRICTED_REASONS: list = [
RestrictedReasons.ALL_WORK_AREAS_COMPLETED.lower(),
RestrictedReasons.DAILY_LIMIT.lower(),
RestrictedReasons.EXTERNAL.lower(),
RestrictedReasons.FOTA.lower(),
RestrictedReasons.FROST.lower(),
RestrictedReasons.NONE.lower(),
RestrictedReasons.NOT_APPLICABLE.lower(),
RestrictedReasons.PARK_OVERRIDE.lower(),
RestrictedReasons.SENSOR.lower(),
RestrictedReasons.WEEK_SCHEDULE.lower(),
]
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}
@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]
SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="battery_percent",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.battery.battery_percent,
),
AutomowerSensorEntityDescription(
key="mode",
translation_key="mode",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: [option.lower() for option in list(MowerModes)],
value_fn=(
lambda data: data.mower.mode.lower()
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=lambda data: data.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=lambda data: data.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=lambda data: data.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=lambda data: data.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=lambda data: data.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=lambda data: data.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=lambda data: data.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=lambda data: data.statistics.total_drive_distance,
),
AutomowerSensorEntityDescription(
key="next_start_timestamp",
translation_key="next_start_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: naive_to_aware(
data.planner.next_start_datetime_naive,
ZoneInfo(str(dt_util.DEFAULT_TIME_ZONE)),
),
),
AutomowerSensorEntityDescription(
key="error",
translation_key="error",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: ERROR_KEY_LIST,
value_fn=lambda data: (
"no_error" if data.mower.error_key is None else data.mower.error_key
),
),
AutomowerSensorEntityDescription(
key="restricted_reason",
translation_key="restricted_reason",
device_class=SensorDeviceClass.ENUM,
option_fn=lambda data: RESTRICTED_REASONS,
value_fn=lambda data: data.planner.restricted_reason.lower(),
),
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,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
AutomowerSensorEntity(mower_id, coordinator, description)
for mower_id in coordinator.data
for description in SENSOR_TYPES
if description.exists_fn(coordinator.data[mower_id])
)
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)