"""Commands for the Automower.""" import datetime import logging import zoneinfo from dataclasses import dataclass from typing import Any from .auth import AbstractAuth from .exceptions import ( FeatureNotSupportedError, WorkAreasDifferentError, ) from .model import ( Calendar, HeadlightModes, MowerAttributes, Tasks, ) from .utils import timedelta_to_minutes _LOGGER = logging.getLogger(__name__) FEATURE_NOT_SUPPORTED_MSG = "This mower does not support this command." @dataclass class AutomowerEndpoint: """Endpoint URLs for the AutomowerConnect API.""" mowers = "mowers/" "List data for all mowers linked to a user." actions = "mowers/{mower_id}/actions" "Accepts actions to control a mower linked to a user." calendar = "mowers/{mower_id}/calendar" "Update the calendar on the mower." messages = "mowers/{mower_id}/messages" "List data for all mowers linked to a user." settings = "mowers/{mower_id}/settings" "Update the settings on the mower." reset_cutting_blade_usage_time = ( "mowers/{mower_id}/statistics/resetCuttingBladeUsageTime" ) """Reset the cutting blade usage time. Same function that is available in the Automower Connect app. The statistics value cuttingBladeUsageTime will be reset. Can be used when cutting blades are changed on the Automower to know when its time to the blades next time.""" stay_out_zones = "mowers/{mower_id}/stayOutZones/{stay_out_id}" "Enable or disable the stay-out zone." work_area_cutting_height = "mowers/{mower_id}/workAreas/{work_area_id}" "This will update cutting height on the work area." work_area_calendar = "mowers/{mower_id}/workAreas/{work_area_id}/calendar" "Update the calendar for a work area on the mower." error_confirm = "mowers/{mower_id}/errors/confirm" "Confirm mower non-fatal error" class WorkAreaSettings: """Namespace for work area settings commands.""" def __init__( self, client: "MowerCommands", mower_id: str, work_area_id: int, ) -> None: """Initialize WorkAreaSettings for a mower's work area.""" self._client = client self.mower_id = mower_id self.work_area_id = work_area_id if not client.data[mower_id].capabilities.work_areas: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) def _validate_range(self, field: str, value: int) -> None: if not 0 <= value <= 1800: msg = f"{field} must be between 0 and 1800" raise ValueError(msg) async def update( self, *, cutting_height: int | None = None, enabled: bool | None = None, name: str | None = None, orientation: int | None = None, orientation_shift: int | None = None, ) -> None: """Update work area settings.""" if orientation is not None: self._validate_range("orientation", orientation) if orientation_shift is not None: self._validate_range("orientation_shift", orientation_shift) attributes: dict[str, int | bool | str] = {} if cutting_height is not None: attributes["cuttingHeight"] = cutting_height if enabled is not None: attributes["enable"] = enabled if name is not None: attributes["name"] = name if orientation is not None: attributes["orientation"] = orientation if orientation_shift is not None: attributes["orientationShift"] = orientation_shift if not attributes: return payload = { "data": { "type": "workArea", "id": self.work_area_id, "attributes": attributes, } } url = AutomowerEndpoint.work_area_cutting_height.format( mower_id=self.mower_id, work_area_id=self.work_area_id, ) await self._client.auth.patch_json(url, json=payload) async def cutting_height(self, cutting_height: int) -> None: """Set the cutting height for this work area.""" await self.update(cutting_height=cutting_height) async def enabled(self, *, enabled: bool) -> None: """Enable or disable this work area.""" await self.update(enabled=enabled) class MowerCommands: """Sending commands.""" def __init__( self, auth: AbstractAuth, data: dict[str, MowerAttributes], mower_tz: zoneinfo.ZoneInfo, ) -> None: """Send all commands to the API. :param class auth: The AbstractAuth class from aioautomower.auth. """ self.auth = auth self.data = data self.mower_tz = mower_tz def workarea_settings( self, mower_id: str, work_area_id: int, ) -> WorkAreaSettings: """Return a settings helper for a specific work area. :param mower_id: Identifier of the mower. :param work_area_id: Identifier of the work area. :returns: Configured WorkAreaSettings instance. """ return WorkAreaSettings( client=self, mower_id=mower_id, work_area_id=work_area_id, ) async def reset_cutting_blade_usage_time(self, mower_id: str) -> None: """Reset the cutting blade usage time. Same function that is available in the Automower Connect app. The statistics value cuttingBladeUsageTime will be reset. Can be used when cutting blades are changed on the Automower to know when its time to the blades next time. """ url = AutomowerEndpoint.reset_cutting_blade_usage_time.format(mower_id=mower_id) await self.auth.post_json(url) async def resume_schedule(self, mower_id: str) -> None: """Resume schedule. Remove any override on the Planner and let the mower resume to the schedule set by the Calendar. """ body = {"data": {"type": "ResumeSchedule"}} url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def pause_mowing(self, mower_id: str) -> None: """Send pause mowing command to the mower via Rest.""" body = {"data": {"type": "Pause"}} url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def park_until_next_schedule(self, mower_id: str) -> None: """Send park until next schedule command to the mower.""" body = {"data": {"type": "ParkUntilNextSchedule"}} url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def park_until_further_notice(self, mower_id: str) -> None: """Send park until further notice command to the mower.""" body = {"data": {"type": "ParkUntilFurtherNotice"}} url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def park_for( self, mower_id: str, tdelta: datetime.timedelta, external_reason: int | None = None, ) -> None: """Parks the mower for a period of minutes. The mower will drive to the charging station and park for the duration set by the command. If external reason is set the mower will be parked with an external reason. The external reason can be used to set the reason for the parking when you have more than one integration to the API. """ body: dict[str, Any] = { "data": { "type": "Park", "attributes": { "duration": timedelta_to_minutes(tdelta), }, } } if external_reason is not None: if not 200_000 <= external_reason <= 299_999: msg = "External reason must be between 200000 and 299999." raise ValueError(msg) if tdelta >= datetime.timedelta(hours=25): msg = ( "External reason can only be used for park durations less than " "25 hours." ) raise ValueError(msg) if not self.data[mower_id].capabilities.work_areas: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) body["data"]["attributes"]["externalReason"] = external_reason url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def start_in_workarea( self, mower_id: str, work_area_id: int, tdelta: datetime.timedelta, ) -> None: """Start the mower in a work area for a period of minutes.""" if not self.data[mower_id].capabilities.work_areas: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) body = { "data": { "type": "StartInWorkArea", "attributes": { "duration": timedelta_to_minutes(tdelta), "workAreaId": work_area_id, }, } } url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def start_for(self, mower_id: str, tdelta: datetime.timedelta) -> None: """Start the mower for a period of minutes.""" body = { "data": { "type": "Start", "attributes": {"duration": timedelta_to_minutes(tdelta)}, } } url = AutomowerEndpoint.actions.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def set_cutting_height(self, mower_id: str, cutting_height: int) -> None: """Set the cutting height for the mower.""" body = { "data": { "type": "settings", "attributes": {"cuttingHeight": cutting_height}, } } url = AutomowerEndpoint.settings.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def set_datetime( self, mower_id: str, current_time: datetime.datetime | None = None ) -> None: """Set the datetime of the mower. Timestamp in seconds from 1970-01-01. The timestamp needs to be in 24 hours in the local time of the mower. """ current_time = current_time or datetime.datetime.now(tz=self.mower_tz) body = { "data": { "type": "settings", "attributes": { "dateTime": int( current_time.astimezone(self.mower_tz) .replace(tzinfo=datetime.UTC) .timestamp() ) }, } } url = AutomowerEndpoint.settings.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def set_datetime_new( self, mower_id: str, current_time: datetime.datetime | None = None ) -> None: """Set the datetime of the mower. If the current has not tz_info, the mower_tz will be used as tz_info. """ current_time = current_time or datetime.datetime.now(tz=self.mower_tz) body = { "data": { "type": "settings", "attributes": { "timer": { "dateTime": int( current_time.astimezone(self.mower_tz) .replace(tzinfo=datetime.UTC) .timestamp() ), "timeZone": str(self.mower_tz), }, }, } } url = AutomowerEndpoint.settings.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def set_headlight_mode( self, mower_id: str, headlight_mode: HeadlightModes, ) -> None: """Send headlight mode to the mower.""" if not self.data[mower_id].capabilities.headlights: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) body = { "data": { "type": "settings", "attributes": {"headlight": {"mode": headlight_mode.upper()}}, } } url = AutomowerEndpoint.settings.format(mower_id=mower_id) await self.auth.post_json(url, json=body) async def set_calendar( self, mower_id: str, tasks: Tasks, ) -> None: """Send calendar task to the mower.""" if not self.data[mower_id].capabilities.work_areas: body = { "data": { "type": "calendar", "attributes": tasks.to_dict(), } } url = AutomowerEndpoint.calendar.format(mower_id=mower_id) await self.auth.post_json(url, json=body) if self.data[mower_id].capabilities.work_areas: task_list: list[Calendar] = tasks.tasks first_work_area_id = None for task in task_list: work_area_id = task.work_area_id if first_work_area_id is None: first_work_area_id = work_area_id elif work_area_id != first_work_area_id: msg = "Only identical work areas are allowed in one command." raise WorkAreasDifferentError(msg) body = { "data": { "type": "calendar", "attributes": tasks.to_dict(), } } url = AutomowerEndpoint.work_area_calendar.format( mower_id=mower_id, work_area_id=work_area_id ) await self.auth.post_json(url, json=body) async def switch_stay_out_zone( self, mower_id: str, stay_out_zone_id: str, *, switch: bool ) -> None: """Enable or disable a stay out zone.""" if not self.data[mower_id].capabilities.stay_out_zones: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) body = { "data": { "type": "stayOutZone", "id": stay_out_zone_id, "attributes": {"enable": switch}, } } url = AutomowerEndpoint.stay_out_zones.format( mower_id=mower_id, stay_out_id=stay_out_zone_id ) await self.auth.patch_json(url, json=body) async def error_confirm(self, mower_id: str) -> None: """Confirm non-fatal mower error.""" if not self.data[mower_id].capabilities.can_confirm_error: msg = FEATURE_NOT_SUPPORTED_MSG raise FeatureNotSupportedError(msg) url = AutomowerEndpoint.error_confirm.format(mower_id=mower_id) await self.auth.post_json(url)