diff --git a/custom_components/mammotion/__init__.py b/custom_components/mammotion/__init__.py index aa97cc5..c792035 100644 --- a/custom_components/mammotion/__init__.py +++ b/custom_components/mammotion/__init__.py @@ -16,6 +16,7 @@ CONF_SESSION_DATA, CONF_USE_WIFI, DEFAULT_RETRY_COUNT, + DOMAIN, ) from .coordinator import MammotionDataUpdateCoordinator @@ -78,6 +79,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> entry.runtime_data = mammotion_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # need to register service for triggering tasks + # hass.services.async_register(DOMAIN, SERVICE_START_tASK, async_start_mowing) + return True diff --git a/custom_components/mammotion/coordinator.py b/custom_components/mammotion/coordinator.py index 3a2bd66..57d0bff 100644 --- a/custom_components/mammotion/coordinator.py +++ b/custom_components/mammotion/coordinator.py @@ -266,22 +266,23 @@ async def async_send_command(self, command: str, **kwargs: Any) -> None: translation_domain=DOMAIN, translation_key="command_failed" ) from exc - async def async_plan_route(self) -> None: + async def async_plan_route(self, operation_settings: OperationSettings) -> None: """Plan mow.""" route_information = GenerateRouteInformation( - one_hashs=self._operation_settings.areas, - rain_tactics=self._operation_settings.rain_tactics, - speed=self._operation_settings.speed, - ultra_wave=self._operation_settings.ultra_wave, # touch no touch etc - toward=self._operation_settings.toward, # is just angle - toward_included_angle=self._operation_settings.toward_included_angle, # angle type relative etc - blade_height=self._operation_settings.blade_height, - channel_mode=self._operation_settings.channel_mode, # line mode is grid single double or single2 - channel_width=self._operation_settings.channel_width, - job_mode=self._operation_settings.job_mode, # taskMode - edge_mode=self._operation_settings.border_mode, # border laps - path_order=create_path_order(self._operation_settings, self.device_name), - obstacle_laps=self._operation_settings.obstacle_laps, + one_hashs=operation_settings.areas, + rain_tactics=operation_settings.rain_tactics, + speed=operation_settings.speed, + ultra_wave=operation_settings.ultra_wave, # touch no touch etc + toward=operation_settings.toward, # is just angle + toward_included_angle=operation_settings.toward_included_angle, # angle relative to grid?? + toward_mode=operation_settings.toward_mode, + blade_height=operation_settings.blade_height, + channel_mode=operation_settings.channel_mode, # line mode is grid single double or single2 + channel_width=operation_settings.channel_width, + job_mode=operation_settings.job_mode, # taskMode + edge_mode=operation_settings.border_mode, # border laps + path_order=create_path_order(operation_settings, self.device_name), + obstacle_laps=operation_settings.obstacle_laps, ) await self.async_send_command( diff --git a/custom_components/mammotion/lawn_mower.py b/custom_components/mammotion/lawn_mower.py index 799b897..548b97c 100644 --- a/custom_components/mammotion/lawn_mower.py +++ b/custom_components/mammotion/lawn_mower.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Any + +import voluptuous as vol from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, @@ -9,7 +12,10 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pymammotion.data.model.device_config import OperationSettings from pymammotion.data.model.report_info import ReportData from pymammotion.proto import has_field from pymammotion.proto.luba_msg import RptDevStatus @@ -20,6 +26,61 @@ from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity +SERVICE_START_MOWING = "start_mow" + +START_MOW_SCHEMA = { + vol.Optional("is_mow", default=True): cv.boolean, + vol.Optional("is_dump", default=True): cv.boolean, + vol.Optional("is_edge", default=False): cv.boolean, + vol.Optional("collect_grass_frequency", default=10): vol.All( + vol.Coerce(int), vol.Range(min=5, max=100) + ), + vol.Optional("job_mode", default=0): vol.Coerce(int), + vol.Optional("job_version", default=0): vol.Coerce(int), + vol.Optional("job_id", default=0): vol.Coerce(int), + vol.Optional("speed", default=0.3): vol.All( + vol.Coerce(float), vol.Range(min=0.2, max=1.2) + ), + vol.Optional("ultra_wave", default=2): vol.In([0, 1, 2, 10]), + vol.Optional("channel_mode", default=0): vol.In([0, 1, 2, 3]), + vol.Optional("channel_width", default=25): vol.All( + vol.Coerce(int), vol.Range(min=20, max=35) + ), + vol.Optional("rain_tactics", default=1): vol.In([0, 1]), + vol.Optional("blade_height", default=25): vol.All( + vol.Coerce(int), vol.Range(min=15, max=100) + ), + vol.Optional("path_order", default=0): vol.In([0, 1]), + vol.Optional("toward", default=0): vol.All( + vol.Coerce(int), vol.Range(min=-180, max=180) + ), + vol.Optional("toward_included_angle", default=0): vol.All( + vol.Coerce(int), vol.Range(min=-180, max=180) + ), + vol.Optional("toward_mode", default=0): vol.In([0, 1, 2]), + vol.Optional("border_mode", default=1): vol.In([0, 1, 2, 3, 4]), + vol.Optional("obstacle_laps", default=1): vol.In([0, 1, 2, 3, 4]), + vol.Optional("start_progress", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Required("areas"): vol.All( + cv.ensure_list, [cv.entity_id] + ), # This assumes `areas` are entity IDs from the integration +} + + +def get_entity_attribute(hass, entity_id, attribute_name): + # Get the state object of the entity + entity = hass.states.get(entity_id) + + # Check if the entity exists and has attributes + if entity and attribute_name in entity.attributes: + # Return the specific attribute + return entity.attributes[attribute_name] + else: + # Return None if the entity or attribute does not exist + return None + async def async_setup_entry( hass: HomeAssistant, @@ -30,6 +91,12 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities([MammotionLawnMowerEntity(coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_START_MOWING, START_MOW_SCHEMA, "async_start_mowing" + ) + class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): """Representation of a Mammotion lawn mower.""" @@ -81,8 +148,24 @@ def activity(self) -> LawnMowerActivity | None: return LawnMowerActivity.DOCKED return None - async def async_start_mowing(self) -> None: + async def async_start_mowing(self, **kwargs: Any) -> None: """Start mowing.""" + if kwargs: + entity_ids = kwargs.get("areas", []) + + attributes = [ + get_entity_attribute(self.hass, entity_id, "hash") + for entity_id in entity_ids + if get_entity_attribute(self.hass, entity_id, "hash") is not None + ] + kwargs["areas"] = attributes + operational_settings = OperationSettings.from_dict(kwargs) + LOGGER.debug(kwargs) + await self.coordinator.async_plan_route(operational_settings) + await self.coordinator.async_send_command("start_job") + await self.coordinator.async_request_iot_sync() + return + # check if job in progress # if self.rpt_dev_status is None: @@ -91,9 +174,9 @@ async def async_start_mowing(self) -> None: ) work_area = self.report_data.work.area >> 16 - if work_area > 0 and ( - self.rpt_dev_status.sys_status == WorkMode.MODE_PAUSE - or self.rpt_dev_status.sys_status == WorkMode.MODE_READY + if work_area > 0 and self.rpt_dev_status.sys_status in ( + WorkMode.MODE_PAUSE, + WorkMode.MODE_READY, ): try: await self.coordinator.async_send_command("resume_execute_task") @@ -103,17 +186,13 @@ async def async_start_mowing(self) -> None: translation_domain=DOMAIN, translation_key="resume_failed" ) from exc try: - await self.coordinator.async_plan_route() + await self.coordinator.async_plan_route(self.coordinator.operation_settings) await self.coordinator.async_send_command("start_job") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="start_failed" ) from exc - finally: - self.coordinator.async_set_updated_data( - self.coordinator.manager.mower(self.coordinator.device_name) - ) async def async_dock(self) -> None: """Start docking.""" diff --git a/custom_components/mammotion/services.yaml b/custom_components/mammotion/services.yaml new file mode 100644 index 0000000..46cc241 --- /dev/null +++ b/custom_components/mammotion/services.yaml @@ -0,0 +1,212 @@ +start_mow: + target: + entity: + integration: mammotion + domain: lawn_mower + fields: + is_mow: + example: true + default: true + required: false + selector: + boolean: + is_dump: + example: true + default: true + required: false + selector: + boolean: + is_edge: + example: false + default: false + required: false + selector: + boolean: + collect_grass_frequency: + example: 10 + default: 10 + required: false + selector: + number: + min: 5 + max: 100 + unit_of_measurement: "m²" + job_mode: + example: 0 + default: 0 + required: false + selector: + number: + job_version: + example: 0 + default: 0 + required: false + selector: + number: + job_id: + example: 0 + default: 0 + required: false + selector: + number: + speed: + example: 0.3 + default: 0.3 + required: false + selector: + number: + min: 0.2 + max: 1.2 + step: 0.1 + mode: box + unit_of_measurement: "m/s" + ultra_wave: + example: 2 + default: 2 + selector: + select: + options: + - value: 0 + label: "Direct Touch" + - value: 1 + label: "Slow Touch" + - value: 2 + label: "Less Touch" + - value: 10 + label: "No Touch" + required: false + channel_mode: + example: 0 + default: 0 + required: false + selector: + select: + options: + - value: 0 + label: "Single Grid" + - value: 1 + label: "Double Grid" + - value: 2 + label: "Segment Grid" + - value: 3 + label: "No Grid" + channel_width: + example: 25 + default: 25 + required: false + selector: + number: + min: 20 + max: 35 + rain_tactics: + example: 1 + default: 1 + required: false + selector: + options: + - value: 0 + label: "Off" + - value: 1 + label: "On" + blade_height: + example: 0 + default: 25 + required: false + selector: + number: + min: 15 + max: 100 + step: 5 + unit_of_measurement: "cm" + path_order: + example: 0 + default: 0 + selector: + select: + options: + - value: 0 + label: "Border First" + - value: 1 + label: "Grid First" + required: false + toward: + example: 0 + default: 0 + required: false + selector: + number: + min: -180 + max: 180 + unit_of_measurement: degrees + toward_included_angle: + example: 0 + default: 0 + required: false + selector: + number: + min: -180 + max: 180 + unit_of_measurement: degrees + toward_mode: + example: 0 + default: 0 + selector: + select: + options: + - value: 0 + label: "Relative Angle" + - value: 1 + label: "Absolute Angle" + - value: 2 + label: "Random Angle" + required: false + border_mode: + example: 1 + default: 1 + selector: + select: + options: + - value: 0 + label: "None" + - value: 1 + label: "One Lap" + - value: 2 + label: "Two Laps" + - value: 3 + label: "Three Laps" + - value: 4 + label: "Four Laps" + required: false + obstacle_laps: + example: 1 + default: 1 + selector: + select: + options: + - value: 0 + label: "None" + - value: 1 + label: "One Lap" + - value: 2 + label: "Two Laps" + - value: 3 + label: "Three Laps" + - value: 4 + label: "Four Laps" + required: false + start_progress: + example: 0 + default: 0 + required: false + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + areas: + required: true + selector: + entity: + multiple: true + integration: mammotion + domain: switch diff --git a/custom_components/mammotion/strings.json b/custom_components/mammotion/strings.json index 272b35b..3d9deef 100644 --- a/custom_components/mammotion/strings.json +++ b/custom_components/mammotion/strings.json @@ -141,6 +141,98 @@ } } }, + "services": { + "start_mow": { + "name": "Start Mowing", + "description": "Start the mowing operation with custom settings.", + "fields": { + "is_mow": { + "name": "Is Mow", + "description": "Whether mowing is active." + }, + "is_dump": { + "name": "Is Dump", + "description": "Whether grass dumping is active." + }, + "is_edge": { + "name": "Is Edge", + "description": "Whether edge mode is active." + }, + "collect_grass_frequency": { + "name": "Grass Collection Frequency", + "description": "Frequency to collect grass (in minutes)." + }, + "job_mode": { + "name": "Job Mode", + "description": "Job mode for cutting." + }, + "job_version": { + "name": "Job Version", + "description": "Job version." + }, + "job_id": { + "name": "Job ID", + "description": "Job ID." + }, + "speed": { + "name": "Speed", + "description": "Mowing speed." + }, + "ultra_wave": { + "name": "Ultra Wave", + "description": "Bypass strategy for mowing." + }, + "channel_mode": { + "name": "Channel Mode", + "description": "Channel mode (grid, single, double, or single2)." + }, + "channel_width": { + "name": "Channel Width", + "description": "Width of the mowing channel (in cm)." + }, + "rain_tactics": { + "name": "Rain Tactics", + "description": "Rain handling tactics." + }, + "blade_height": { + "name": "Blade Height", + "description": "Height of the blade." + }, + "path_order": { + "name": "Path Order", + "description": "Mowing path order (border first or grid first)." + }, + "toward": { + "name": "Toward", + "description": "Direction angle for mowing." + }, + "toward_included_angle": { + "name": "Toward Included Angle", + "description": "Type of angle to use (relative, absolute, or random)." + }, + "toward_mode": { + "name": "Toward Mode", + "description": "Toward mode." + }, + "border_mode": { + "name": "Border Patrol Mode", + "description": "Border patrol mode (number of laps)." + }, + "obstacle_laps": { + "name": "Obstacle Laps", + "description": "Number of laps around obstacles." + }, + "start_progress": { + "name": "Start Progress", + "description": "Starting progress percentage." + }, + "areas": { + "name": "Areas", + "description": "List of areas to mow (represented as integers)." + } + } + } + }, "exceptions": { "device_not_ready": { "message": "Device is not ready." diff --git a/custom_components/mammotion/switch.py b/custom_components/mammotion/switch.py index 8dcbea8..61728c5 100644 --- a/custom_components/mammotion/switch.py +++ b/custom_components/mammotion/switch.py @@ -86,7 +86,7 @@ async def async_setup_entry( ) -> None: """Set up the Mammotion switch entities.""" coordinator = entry.runtime_data - added_areas: set[int] = set() + added_areas: set[str] = set() @callback def add_entities() -> None: @@ -216,6 +216,7 @@ def __init__( self.coordinator = coordinator self.entity_description = entity_description self._attr_translation_key = entity_description.key + self._attr_extra_state_attributes = {"hash": entity_description.area} # TODO grab defaults from operation_settings self._attr_is_on = False # Default state diff --git a/custom_components/mammotion/translations/en.json b/custom_components/mammotion/translations/en.json index 8bb9fc7..cdca1eb 100644 --- a/custom_components/mammotion/translations/en.json +++ b/custom_components/mammotion/translations/en.json @@ -238,6 +238,98 @@ "name": "Device Tracking" } }, + "services": { + "start_mow": { + "name": "Start Mowing", + "description": "Start the mowing operation with custom settings.", + "fields": { + "is_mow": { + "name": "Is Mow", + "description": "Whether mowing is active. (Yuka)" + }, + "is_dump": { + "name": "Is Dump", + "description": "Whether grass dumping is active. (Yuka)" + }, + "is_edge": { + "name": "Is Edge", + "description": "Whether edge mode is active. (Yuka)" + }, + "collect_grass_frequency": { + "name": "Grass Collection Frequency", + "description": "Frequency to collect grass (in meters squared). (Yuka)" + }, + "job_mode": { + "name": "Job Mode", + "description": "Job mode for cutting." + }, + "job_version": { + "name": "Job Version", + "description": "Job version." + }, + "job_id": { + "name": "Job ID", + "description": "Job ID." + }, + "speed": { + "name": "Speed", + "description": "Mowing speed." + }, + "ultra_wave": { + "name": "Obstacle Detection", + "description": "Bypass strategy for mowing." + }, + "channel_mode": { + "name": "Channel Mode", + "description": "Channel mode (grid, single, double, or single2)." + }, + "channel_width": { + "name": "Channel Width", + "description": "Width of the mowing channel (in cm)." + }, + "rain_tactics": { + "name": "Rain Detection", + "description": "Rain detection." + }, + "blade_height": { + "name": "Blade Height", + "description": "Height of the blade." + }, + "path_order": { + "name": "Path Order", + "description": "Mowing path order (border first or grid first)." + }, + "toward": { + "name": "Angle", + "description": "Direction angle for mowing." + }, + "toward_included_angle": { + "name": "Angle Relative to Grid?", + "description": "When selecting grid change the second angle." + }, + "toward_mode": { + "name": "Direction Mode", + "description": "Angle mode." + }, + "border_mode": { + "name": "Border Patrol Mode", + "description": "Border patrol mode (number of laps)." + }, + "obstacle_laps": { + "name": "Obstacle Laps", + "description": "Number of laps around obstacles." + }, + "start_progress": { + "name": "Start Progress", + "description": "Starting progress percentage." + }, + "areas": { + "name": "Areas", + "description": "List of areas to mow (represented as integers)." + } + } + } + }, "exceptions": { "device_not_ready": { "message": "Device is not ready."