Skip to content

Commit 1c034ea

Browse files
committed
tidy up satellite sensors, another ble fix, spino improvements and error sensors
1 parent fb51eae commit 1c034ea

19 files changed

Lines changed: 898 additions & 55 deletions

File tree

custom_components/mammotion/coordinator.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@
110110
# the ``map_sync_status`` diagnostic ENUM sensor that surfaces it.
111111
MAP_SYNC_STATUSES = ("synced", "syncing", "out_of_sync")
112112

113+
# Cloud response code returned by the stream-subscription endpoint when the
114+
# device is unreachable ("Device not responding. Please check the network
115+
# connection"). Treated as a device-offline signal.
116+
DEVICE_NOT_RESPONDING_CODE = 50504
117+
113118

114119
class MammotionBaseUpdateCoordinator[DataT](DataUpdateCoordinator[DataT]): # type: ignore[misc]
115120
"""Mammotion DataUpdateCoordinator."""
@@ -206,6 +211,21 @@ async def async_check_stream_expiry(
206211
self.set_stream_data(stream_data)
207212
self._stream_data_fetched_at = time.monotonic()
208213

214+
# A 50504 means the cloud couldn't reach the device — bail out
215+
# cleanly rather than continuing on to the Agora setup with no data.
216+
if (
217+
stream_data is not None
218+
and stream_data.code == DEVICE_NOT_RESPONDING_CODE
219+
):
220+
LOGGER.warning(
221+
"Stream subscription for %s reports device not responding "
222+
"(code %s: %s)",
223+
self.device_name,
224+
stream_data.code,
225+
stream_data.msg,
226+
)
227+
return None, self._agora_response
228+
209229
if stream_data is not None and stream_data.data is not None:
210230
LOGGER.debug("Received stream data: %s", stream_data)
211231

@@ -2433,6 +2453,47 @@ async def async_restore_data(self) -> None:
24332453
if handle is not None:
24342454
handle.restore_device(empty)
24352455

2456+
def get_error_code(self) -> int:
2457+
"""Return the absolute error code of the most recent fault, or 0."""
2458+
try:
2459+
return int(abs(self.data.pool_state.error_log[0].code))
2460+
except IndexError:
2461+
return 0
2462+
2463+
def get_error_time(self) -> datetime.datetime | None:
2464+
"""Return the timestamp of the most recent fault as a UTC datetime, or None."""
2465+
try:
2466+
return datetime.datetime.fromtimestamp(
2467+
self.data.pool_state.error_log[0].timestamp, datetime.UTC
2468+
)
2469+
except IndexError:
2470+
return None
2471+
2472+
def get_error_message(self) -> str:
2473+
"""Return a human-readable description of the most recent fault."""
2474+
try:
2475+
error_code = abs(self.data.pool_state.error_log[0].code)
2476+
error_info: ErrorInfo = self.data.errors.error_codes[f"{error_code}"]
2477+
implication = (
2478+
getattr(error_info, f"{self.hass.config.language}_implication")
2479+
if hasattr(error_info, f"{self.hass.config.language}_implication")
2480+
else error_info.en_implication
2481+
)
2482+
solution = (
2483+
getattr(error_info, f"{self.hass.config.language}_solution")
2484+
if hasattr(error_info, f"{self.hass.config.language}_solution")
2485+
else error_info.en_solution
2486+
)
2487+
if implication == "":
2488+
implication = error_info.en_implication
2489+
if solution == "":
2490+
solution = error_info.en_solution
2491+
return f"{error_info.module}: {implication}, {solution}"
2492+
except IndexError:
2493+
return "No Error"
2494+
except KeyError:
2495+
return "Error message not found"
2496+
24362497
async def _async_update_data(self) -> PoolCleanerDevice:
24372498
"""Return current pool cleaner state from the device handle.
24382499
@@ -2454,6 +2515,8 @@ async def _async_update_data(self) -> PoolCleanerDevice:
24542515
for check_version in check_versions:
24552516
if check_version.device_id == self.device.iot_id:
24562517
self.data.apply_version_check(check_version)
2518+
if not self.data.errors.error_codes:
2519+
self.data.errors.error_codes = await http.get_all_error_codes()
24572520
except ReLoginRequiredError as err:
24582521
raise ConfigEntryAuthFailed(
24592522
f"Re-authentication required for Mammotion account: {err}"

custom_components/mammotion/manifest.json

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,11 @@
1616
"connectable": true
1717
}
1818
],
19-
"codeowners": [
20-
"@mikey0000"
21-
],
19+
"codeowners": ["@mikey0000"],
2220
"config_flow": true,
23-
"dependencies": [
24-
"bluetooth_adapters"
25-
],
21+
"dependencies": ["bluetooth_adapters"],
2622
"documentation": "https://github.com/mikey0000/Mammotion-HA/wiki",
27-
"loggers": [
28-
"pymammotion"
29-
],
23+
"loggers": ["pymammotion"],
3024
"iot_class": "local_push",
31-
"requirements": [
32-
"pymammotion==0.8.6"
33-
]
25+
"requirements": ["pymammotion==0.8.7"]
3426
}

custom_components/mammotion/select.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,41 @@ class MammotionSpinoSelectEntityDescription(SelectEntityDescription):
6565
SPINO_SELECT_ENTITIES: tuple[MammotionSpinoSelectEntityDescription, ...] = (
6666
MammotionSpinoSelectEntityDescription(
6767
key="spino_work_mode",
68-
options=[mode.name for mode in SpinoWorkMode],
68+
# Only real cleaning modes are selectable. RECHARGE (0, return-to-charge)
69+
# and UNKNOWN (-1, sentinel) are valid *reported* values — surfaced by the
70+
# spino_work_mode sensor — but they're not modes a user can start, so they
71+
# are excluded from the select's options.
72+
options=[
73+
mode.name
74+
for mode in SpinoWorkMode
75+
if mode not in (SpinoWorkMode.UNKNOWN, SpinoWorkMode.RECHARGE)
76+
],
6977
current_fn=lambda spino_data: spino_data.pool_state.work_mode.name,
7078
set_fn=lambda coordinator, value: coordinator.async_set_work_mode(
7179
SpinoWorkMode[value].value
7280
),
7381
),
7482
MammotionSpinoSelectEntityDescription(
7583
key="spino_wall_material",
76-
options=[material.name for material in WallMaterial],
84+
# UNKNOWN (-1) is a sentinel for an unreported value, not a user choice.
85+
options=[
86+
material.name
87+
for material in WallMaterial
88+
if material is not WallMaterial.UNKNOWN
89+
],
7790
current_fn=lambda spino_data: spino_data.pool_state.wall_material.name,
7891
set_fn=lambda coordinator, value: coordinator.async_set_wall_material(
7992
WallMaterial[value].value
8093
),
8194
),
8295
MammotionSpinoSelectEntityDescription(
8396
key="spino_bottom_type",
84-
options=[bottom.name for bottom in PoolBottomType],
97+
# UNKNOWN (-1) is a sentinel for an unreported value, not a user choice.
98+
options=[
99+
bottom.name
100+
for bottom in PoolBottomType
101+
if bottom is not PoolBottomType.UNKNOWN
102+
],
85103
current_fn=lambda spino_data: spino_data.pool_state.bottom_type.name,
86104
set_fn=lambda coordinator, value: coordinator.async_set_bottom_type(
87105
PoolBottomType[value].value

custom_components/mammotion/sensor.py

Lines changed: 158 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ class MammotionErrorSensorEntityDescription(SensorEntityDescription):
142142
value_fn: Callable[[MammotionDeviceErrorUpdateCoordinator, MowingDevice], StateType]
143143

144144

145+
@dataclass(frozen=True, kw_only=True)
146+
class MammotionSpinoErrorSensorEntityDescription(SensorEntityDescription):
147+
"""Describes a Spino error-log sensor entity."""
148+
149+
value_fn: Callable[[MammotionSpinoCoordinator], StateType]
150+
151+
145152
LUBA_SENSOR_ONLY_TYPES: tuple[MammotionSensorEntityDescription, ...] = (
146153
MammotionSensorEntityDescription(
147154
key="blade_height",
@@ -326,21 +333,19 @@ class MammotionErrorSensorEntityDescription(SensorEntityDescription):
326333
entity_category=EntityCategory.DIAGNOSTIC,
327334
),
328335
MammotionSensorEntityDescription(
329-
key="l1_satellites",
336+
key="pos_level",
330337
state_class=SensorStateClass.MEASUREMENT,
331338
device_class=None,
332339
native_unit_of_measurement=None,
333-
value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 0)
334-
& 255,
340+
value_fn=lambda mower_data: mower_data.report_data.rtk.pos_level,
335341
entity_category=EntityCategory.DIAGNOSTIC,
336342
),
337343
MammotionSensorEntityDescription(
338-
key="l2_satellites",
344+
key="age",
339345
state_class=SensorStateClass.MEASUREMENT,
340-
device_class=None,
341-
native_unit_of_measurement=None,
342-
value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 8)
343-
& 255,
346+
device_class=SensorDeviceClass.DURATION,
347+
native_unit_of_measurement=UnitOfTime.SECONDS,
348+
value_fn=lambda mower_data: mower_data.report_data.rtk.age,
344349
entity_category=EntityCategory.DIAGNOSTIC,
345350
),
346351
# MammotionSensorEntityDescription(
@@ -441,6 +446,73 @@ class MammotionErrorSensorEntityDescription(SensorEntityDescription):
441446
),
442447
)
443448

449+
# Luba 2 / Yuka (non-RTK) only — APK refreshNonRtkDeviceUI shows these;
450+
# Luba 1 and standard RTK devices do not display them.
451+
LUBA_2_YUKA_SIGNAL_TYPES: tuple[MammotionSensorEntityDescription, ...] = (
452+
MammotionSensorEntityDescription(
453+
key="l1_satellites",
454+
state_class=SensorStateClass.MEASUREMENT,
455+
device_class=None,
456+
native_unit_of_measurement=None,
457+
value_fn=lambda mower_data: (mower_data.report_data.rtk.dis_status >> 16) & 255,
458+
entity_category=EntityCategory.DIAGNOSTIC,
459+
),
460+
MammotionSensorEntityDescription(
461+
key="l2_satellites",
462+
state_class=SensorStateClass.MEASUREMENT,
463+
device_class=None,
464+
native_unit_of_measurement=None,
465+
value_fn=lambda mower_data: (mower_data.report_data.rtk.dis_status >> 24) & 255,
466+
entity_category=EntityCategory.DIAGNOSTIC,
467+
),
468+
MammotionSensorEntityDescription(
469+
key="co_view_l1",
470+
state_class=SensorStateClass.MEASUREMENT,
471+
device_class=None,
472+
native_unit_of_measurement=None,
473+
value_fn=lambda mower_data: mower_data.report_data.rtk.co_view_stars & 255,
474+
entity_category=EntityCategory.DIAGNOSTIC,
475+
),
476+
MammotionSensorEntityDescription(
477+
key="co_view_l2",
478+
state_class=SensorStateClass.MEASUREMENT,
479+
device_class=None,
480+
native_unit_of_measurement=None,
481+
value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 8)
482+
& 255,
483+
entity_category=EntityCategory.DIAGNOSTIC,
484+
),
485+
MammotionSensorEntityDescription(
486+
key="rtk_signal",
487+
state_class=SensorStateClass.MEASUREMENT,
488+
device_class=None,
489+
native_unit_of_measurement=None,
490+
value_fn=lambda mower_data: (mower_data.report_data.rtk.dis_status >> 40) & 255,
491+
entity_category=EntityCategory.DIAGNOSTIC,
492+
),
493+
MammotionSensorEntityDescription(
494+
key="device_signal",
495+
state_class=None,
496+
device_class=SensorDeviceClass.ENUM,
497+
native_unit_of_measurement=None,
498+
value_fn=lambda mower_data: (mower_data.report_data.rtk.dis_status >> 32) & 255,
499+
entity_category=EntityCategory.DIAGNOSTIC,
500+
),
501+
)
502+
503+
# Luba 1 only — APK refreshLuba1ModeUI shows base_link_status (connection_to_ref);
504+
# Luba 2 / Yuka and RTK devices hide it.
505+
LUBA_1_SIGNAL_TYPES: tuple[MammotionSensorEntityDescription, ...] = (
506+
MammotionSensorEntityDescription(
507+
key="base_link_status",
508+
state_class=SensorStateClass.MEASUREMENT,
509+
device_class=None,
510+
native_unit_of_measurement=None,
511+
value_fn=lambda mower_data: (mower_data.report_data.rtk.dis_status >> 48) & 255,
512+
entity_category=EntityCategory.DIAGNOSTIC,
513+
),
514+
)
515+
444516
WORK_SENSOR_TYPES: tuple[MammotionWorkSensorEntityDescription, ...] = (
445517
MammotionWorkSensorEntityDescription(
446518
key="work_area",
@@ -548,6 +620,44 @@ class MammotionErrorSensorEntityDescription(SensorEntityDescription):
548620
),
549621
)
550622

623+
SPINO_ERROR_SENSOR_TYPES: tuple[MammotionSpinoErrorSensorEntityDescription, ...] = (
624+
MammotionSpinoErrorSensorEntityDescription(
625+
key="spino_error_time",
626+
device_class=SensorDeviceClass.TIMESTAMP,
627+
value_fn=lambda coordinator: coordinator.get_error_time(),
628+
entity_category=EntityCategory.DIAGNOSTIC,
629+
),
630+
MammotionSpinoErrorSensorEntityDescription(
631+
key="spino_error_message",
632+
state_class=None,
633+
native_unit_of_measurement=None,
634+
device_class=None,
635+
value_fn=lambda coordinator: (
636+
msg[:255] if (msg := coordinator.get_error_message()) is not None else None
637+
),
638+
entity_category=EntityCategory.DIAGNOSTIC,
639+
),
640+
MammotionSpinoErrorSensorEntityDescription(
641+
key="spino_error_code",
642+
state_class=None,
643+
native_unit_of_measurement=None,
644+
device_class=None,
645+
value_fn=lambda coordinator: coordinator.get_error_code(),
646+
entity_category=EntityCategory.DIAGNOSTIC,
647+
),
648+
MammotionSpinoErrorSensorEntityDescription(
649+
key="spino_mqtt_status",
650+
state_class=None,
651+
native_unit_of_measurement=None,
652+
device_class=SensorDeviceClass.ENUM,
653+
options=["online", "offline"],
654+
value_fn=lambda coordinator: "online"
655+
if coordinator.mqtt_device_online
656+
else "offline",
657+
entity_category=EntityCategory.DIAGNOSTIC,
658+
),
659+
)
660+
551661

552662
async def async_setup_entry(
553663
hass: HomeAssistant,
@@ -570,12 +680,25 @@ async def async_setup_entry(
570680
MammotionSensorEntity(mower.reporting_coordinator, description)
571681
for description in LUBA_2_YUKA_ONLY_TYPES
572682
)
573-
if not DeviceType.is_yuka_mini(mower.device.device_name):
683+
entities.extend(
684+
MammotionSensorEntity(mower.reporting_coordinator, description)
685+
for description in LUBA_2_YUKA_SIGNAL_TYPES
686+
)
687+
device_type = DeviceType.value_of_str(
688+
mower.device.device_name, mower.device.product_key
689+
)
690+
if device_type.supports_battery_cycle_count():
574691
entities.extend(
575692
MammotionSensorEntity(mower.reporting_coordinator, description)
576693
for description in MINI_SERIES_EXCLUDED_TYPES
577694
)
578695

696+
if DeviceType.is_luba1(mower.device.device_name, mower.device.product_key):
697+
entities.extend(
698+
MammotionSensorEntity(mower.reporting_coordinator, description)
699+
for description in LUBA_1_SIGNAL_TYPES
700+
)
701+
579702
entities.extend(
580703
MammotionSensorEntity(mower.reporting_coordinator, description)
581704
for description in SENSOR_TYPES
@@ -619,6 +742,10 @@ async def async_setup_entry(
619742
MammotionSpinoSensorEntity(spino.coordinator, description)
620743
for description in SPINO_SENSOR_TYPES
621744
)
745+
entities.extend(
746+
MammotionSpinoErrorSensorEntity(spino.coordinator, description)
747+
for description in SPINO_ERROR_SENSOR_TYPES
748+
)
622749

623750
async_add_entities(entities)
624751

@@ -687,6 +814,28 @@ def native_value(self) -> StateType:
687814
return self.entity_description.value_fn(self.coordinator.data)
688815

689816

817+
class MammotionSpinoErrorSensorEntity(MammotionBaseSpinoEntity, SensorEntity):
818+
"""Sensor entity for a single Spino error-log field (code, time, or message)."""
819+
820+
entity_description: MammotionSpinoErrorSensorEntityDescription
821+
_attr_has_entity_name = True
822+
823+
def __init__(
824+
self,
825+
coordinator: MammotionSpinoCoordinator,
826+
entity_description: MammotionSpinoErrorSensorEntityDescription,
827+
) -> None:
828+
"""Set up MammotionSpinoErrorSensorEntity."""
829+
super().__init__(coordinator, entity_description.key)
830+
self.entity_description = entity_description
831+
self._attr_translation_key = entity_description.key
832+
833+
@property
834+
def native_value(self) -> StateType:
835+
"""Return the state of the sensor."""
836+
return self.entity_description.value_fn(self.coordinator)
837+
838+
690839
class MammotionErrorSensorEntity(MammotionBaseEntity, SensorEntity):
691840
"""Defining the Mammotion Error Sensor."""
692841

0 commit comments

Comments
 (0)