|
28 | 28 | from homeassistant.helpers import device_registry as dr |
29 | 29 | from homeassistant.helpers import entity_registry as er |
30 | 30 | from homeassistant.helpers.debounce import Debouncer |
31 | | -from homeassistant.helpers.event import async_call_later |
| 31 | +from homeassistant.helpers.event import async_call_later, async_track_time_interval |
32 | 32 | from homeassistant.helpers.storage import Store |
33 | 33 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator |
34 | 34 | from mashumaro.exceptions import InvalidFieldValue |
|
50 | 50 | RTKBaseStationDevice, |
51 | 51 | ) |
52 | 52 | from pymammotion.data.model.device_config import OperationSettings, create_path_order |
53 | | -from pymammotion.data.model.hash_list import AreaHashNameList, Plan, SvgMessage |
| 53 | +from pymammotion.data.model.hash_list import Plan, SvgMessage |
54 | 54 | from pymammotion.data.model.pool_state import PoolPlan, SpinoToggle |
55 | 55 | from pymammotion.data.model.report_info import Maintain, NetUsedType |
56 | 56 | from pymammotion.data.mqtt.event import DeviceNotificationEventParams, ThingEventMessage |
|
101 | 101 | MAINTENANCE_INTERVAL = timedelta(minutes=60) |
102 | 102 | DEFAULT_INTERVAL = timedelta(minutes=30) |
103 | 103 | REPORT_INTERVAL = timedelta(minutes=5) |
| 104 | +DYNAMICS_LINE_INTERVAL = timedelta(seconds=10) |
104 | 105 | DEVICE_VERSION_INTERVAL = timedelta(weeks=1) |
105 | 106 | MAP_INTERVAL = timedelta(minutes=60) |
106 | 107 | RTK_INTERVAL = timedelta(hours=5) |
@@ -1457,24 +1458,22 @@ def get_area_entity_name(self, area_hash: int) -> str | None: |
1457 | 1458 | if area_hash == 0: |
1458 | 1459 | return None |
1459 | 1460 |
|
1460 | | - name = None |
1461 | 1461 | _mower_data = cast(MowingDevice, self.data) |
1462 | | - if area_data := _mower_data.map.area.get(area_hash): |
1463 | | - area_frame = area_data.data[0] if len(area_data.data) > 0 else None |
1464 | | - if area_frame is not None: |
1465 | | - area_name: AreaHashNameList | None = next( |
1466 | | - ( |
1467 | | - area |
1468 | | - for area in _mower_data.map.area_name |
1469 | | - if area.hash == area_frame.hash |
1470 | | - ), |
1471 | | - None, |
1472 | | - ) |
1473 | | - name = area_name.name if area_name is not None else None |
1474 | | - else: |
| 1462 | + if area_hash not in _mower_data.map.area: |
1475 | 1463 | return "path" |
1476 | 1464 |
|
1477 | | - return name if name else f"area {area_hash}" |
| 1465 | + # Prefer the user's HA-level entity name over the device-assigned name. |
| 1466 | + entity_reg = er.async_get(self.hass) |
| 1467 | + unique_id = f"{self.unique_name}_{area_hash}" |
| 1468 | + entity_id = entity_reg.async_get_entity_id("switch", DOMAIN, unique_id) |
| 1469 | + if entity_id and (entry := entity_reg.async_get(entity_id)) and entry.name: |
| 1470 | + return entry.name |
| 1471 | + |
| 1472 | + for area in _mower_data.map.computed_areas: |
| 1473 | + if area.hash == area_hash: |
| 1474 | + return area.name |
| 1475 | + |
| 1476 | + return f"area {area_hash}" |
1478 | 1477 |
|
1479 | 1478 | @property |
1480 | 1479 | def map_sync_status(self) -> str: |
@@ -1978,6 +1977,8 @@ async def _async_setup(self) -> None: |
1978 | 1977 | class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): |
1979 | 1978 | """Class to manage fetching mammotion data.""" |
1980 | 1979 |
|
| 1980 | + _dynamics_line_cancel: CALLBACK_TYPE | None = None |
| 1981 | + |
1981 | 1982 | def __init__( |
1982 | 1983 | self, |
1983 | 1984 | hass: HomeAssistant, |
@@ -2046,13 +2047,72 @@ async def _async_update_data(self) -> MowerInfo: |
2046 | 2047 | assert _d is not None |
2047 | 2048 | return _d.mower_state |
2048 | 2049 |
|
| 2050 | + def _device_supports_dynamics_line(self) -> bool: |
| 2051 | + """Return True if this device supports the dynamics-line mow-progress stream.""" |
| 2052 | + device = self.manager.get_device_by_name(self.device_name) |
| 2053 | + firmware = device.device_firmwares.main_controller if device else None |
| 2054 | + return DeviceType.value_of_str(self.device_name).is_support_dynamics_line( |
| 2055 | + firmware |
| 2056 | + ) |
| 2057 | + |
| 2058 | + def _ble_is_connected(self) -> bool: |
| 2059 | + """Return True if BLE transport exists and is currently connected.""" |
| 2060 | + if handle := self.manager.mower(self.device_name): |
| 2061 | + if ble := handle.get_transport(TransportType.BLE): |
| 2062 | + return ble.is_connected |
| 2063 | + return False |
| 2064 | + |
| 2065 | + def _stop_dynamics_line_poll(self) -> None: |
| 2066 | + if self._dynamics_line_cancel is not None: |
| 2067 | + self._dynamics_line_cancel() |
| 2068 | + self._dynamics_line_cancel = None |
| 2069 | + |
| 2070 | + async def _on_sys_status_changed_dynamics(self, sys_status: int) -> None: |
| 2071 | + """Start the dynamics-line poll when mowing over BLE; stop it otherwise.""" |
| 2072 | + if ( |
| 2073 | + sys_status in MOWING_ACTIVE_MODES |
| 2074 | + and self._device_supports_dynamics_line() |
| 2075 | + and self._ble_is_connected() |
| 2076 | + ): |
| 2077 | + if self._dynamics_line_cancel is None: |
| 2078 | + self._dynamics_line_cancel = async_track_time_interval( |
| 2079 | + self.hass, |
| 2080 | + self._fetch_dynamics_line, |
| 2081 | + DYNAMICS_LINE_INTERVAL, |
| 2082 | + ) |
| 2083 | + else: |
| 2084 | + self._stop_dynamics_line_poll() |
| 2085 | + |
| 2086 | + async def _fetch_dynamics_line(self, _now: datetime.datetime) -> None: |
| 2087 | + """Fetch the dynamics line; self-cancels if BLE has disconnected.""" |
| 2088 | + if not self._ble_is_connected(): |
| 2089 | + self._stop_dynamics_line_poll() |
| 2090 | + return |
| 2091 | + try: |
| 2092 | + await self.manager.get_dynamics_line(self.device_name) |
| 2093 | + device = self.manager.get_device_by_name(self.device_name) |
| 2094 | + if device is not None: |
| 2095 | + self.async_set_updated_data(device.mower_state) |
| 2096 | + except ( |
| 2097 | + DeviceOfflineException, |
| 2098 | + NoTransportAvailableError, |
| 2099 | + GatewayTimeoutException, |
| 2100 | + ): |
| 2101 | + pass |
| 2102 | + |
2049 | 2103 | async def _async_setup(self) -> None: |
2050 | 2104 | """Set up coordinator with initial call to get map data.""" |
2051 | 2105 | await super()._async_setup() |
2052 | 2106 | device = self.manager.get_device_by_name(self.device_name) |
2053 | 2107 | if device is None: |
2054 | 2108 | return |
2055 | 2109 |
|
| 2110 | + if handle := self.manager.mower(self.device_name): |
| 2111 | + handle.watch_field( |
| 2112 | + lambda s: s.raw.report_data.dev.sys_status, |
| 2113 | + self._on_sys_status_changed_dynamics, |
| 2114 | + ) |
| 2115 | + |
2056 | 2116 | if not device.enabled or not device.online: |
2057 | 2117 | return |
2058 | 2118 | try: |
@@ -2417,6 +2477,14 @@ async def _async_setup(self) -> None: |
2417 | 2477 | context=0, |
2418 | 2478 | rw=0, |
2419 | 2479 | ) |
| 2480 | + # Fetch pool geometry once at startup. Responses arrive as unsolicited |
| 2481 | + # APP_DOWNLINK_CMD frames and are reassembled by PoolStateReducer. |
| 2482 | + for fetch in (self.async_fetch_pool_map, self.async_fetch_pool_line): |
| 2483 | + with contextlib.suppress( |
| 2484 | + GatewayTimeoutException, |
| 2485 | + NoTransportAvailableError, |
| 2486 | + ): |
| 2487 | + await fetch() |
2420 | 2488 | except DeviceOfflineException: |
2421 | 2489 | self.device.online = False |
2422 | 2490 |
|
|
0 commit comments