Skip to content

Commit 533e55e

Browse files
committed
fix yuka mini detection, make sure auth failures cause the integration to stop, add task services for both mower and spino
1 parent 29f7c16 commit 533e55e

22 files changed

Lines changed: 3759 additions & 74 deletions

custom_components/mammotion/__init__.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,17 @@ async def _on_credentials_updated() -> None:
373373
)
374374

375375
if cloud_available:
376+
377+
async def _on_unrecoverable_auth_error(_: Exception) -> None:
378+
"""Trigger HA re-authentication when all automatic recovery has failed."""
379+
LOGGER.error(
380+
"Mammotion account %s: all auth recovery attempts exhausted — prompting re-login",
381+
account,
382+
)
383+
await mammotion.stop()
384+
raise ConfigEntryAuthFailed()
385+
386+
mammotion.on_unrecoverable_auth_error = _on_unrecoverable_auth_error
376387
store_cloud_credentials(hass, entry, mammotion)
377388

378389
mower_devices, mammotion_rtk_devices, spino_devices = _build_device_list(
@@ -606,18 +617,6 @@ async def _async_refresh_map(_: datetime) -> None:
606617

607618
mammotion.setup_all_mower_watchers()
608619

609-
if has_cloud_account:
610-
611-
async def _on_unrecoverable_auth_error(_: Exception) -> None:
612-
"""Trigger HA re-authentication when all automatic recovery has failed."""
613-
LOGGER.error(
614-
"Mammotion account %s: all auth recovery attempts exhausted — prompting re-login",
615-
account,
616-
)
617-
entry.async_start_reauth(hass)
618-
619-
mammotion.on_unrecoverable_auth_error = _on_unrecoverable_auth_error
620-
621620
async def shutdown_mammotion(_: Event | None = None) -> None:
622621
await mammotion.stop()
623622

custom_components/mammotion/agora_websocket.py

Lines changed: 89 additions & 47 deletions
Large diffs are not rendered by default.

custom_components/mammotion/button.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from homeassistant.helpers.entity import EntityCategory
1313
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1414
from pymammotion.data.model.hash_list import Plan
15+
from pymammotion.data.model.pool_state import PoolPlan
1516
from pymammotion.transport.base import TransportType
1617
from pymammotion.utility.device_type import DeviceType
1718

@@ -48,6 +49,20 @@ class MammotionSpinoButtonEntityDescription(ButtonEntityDescription):
4849
press_fn: Callable[[MammotionSpinoCoordinator], Awaitable[None]]
4950

5051

52+
@dataclass(frozen=True, kw_only=True)
53+
class MammotionSpinoTaskButtonEntityDescription(ButtonEntityDescription):
54+
"""Describes a dynamic per-schedule Spino task button entity.
55+
56+
Mirror of :class:`MammotionTaskButtonSensorEntityDescription` for the
57+
Spino pool cleaner. Spino plans are keyed by a 64-bit ``jobid``; we
58+
stringify it for ``key`` / ``unique_id`` so it survives the HA entity
59+
registry's string-only constraint.
60+
"""
61+
62+
jobid: int
63+
press_fn: Callable[[MammotionSpinoCoordinator, int], Awaitable[None]]
64+
65+
5166
SPINO_BUTTON_SENSORS: tuple[MammotionSpinoButtonEntityDescription, ...] = (
5267
MammotionSpinoButtonEntityDescription(
5368
key="spino_fetch_map",
@@ -194,6 +209,22 @@ async def async_setup_entry(
194209
for entity_description in SPINO_BUTTON_SENSORS
195210
)
196211

212+
# Dynamic per-schedule task buttons — mirrors the mower setup but
213+
# keyed by Spino ``jobid`` (int). Primary purpose: provide an
214+
# addressable HA entity so the rename / enable / delete / copy
215+
# services can target a specific schedule via entity_id.
216+
added_spino_tasks: set[int] = set()
217+
spino_task_entities_by_id: dict[int, MammotionSpinoTaskButtonEntity] = {}
218+
update_spino_tasks = partial(
219+
async_add_spino_task_entities,
220+
spino.coordinator,
221+
added_spino_tasks,
222+
spino_task_entities_by_id,
223+
async_add_entities,
224+
)
225+
update_spino_tasks()
226+
spino.coordinator.async_add_listener(update_spino_tasks)
227+
197228

198229
class MammotionButtonSensorEntity(MammotionBaseEntity, ButtonEntity):
199230
"""Mammotion button sensor entity."""
@@ -348,6 +379,134 @@ def async_remove_entities(
348379
registry.async_remove(entity_id)
349380

350381

382+
class MammotionSpinoTaskButtonEntity(MammotionBaseSpinoEntity, ButtonEntity):
383+
"""Per-schedule Spino task button.
384+
385+
Exists primarily so the schedule-modify services (rename / enable /
386+
disable / delete / copy / edit) can target an addressable HA entity
387+
via ``entity_id``.
388+
389+
Spino does not expose a "start this schedule now" command in the
390+
proto we have today, so the press triggers a refresh of the whole
391+
schedule list — a useful default and the closest analogue to the
392+
mower's ``start_task`` press semantics.
393+
"""
394+
395+
entity_description: MammotionSpinoTaskButtonEntityDescription
396+
_attr_has_entity_name = True
397+
_attr_entity_category = EntityCategory.CONFIG
398+
399+
def __init__(
400+
self,
401+
coordinator: MammotionSpinoCoordinator,
402+
entity_description: MammotionSpinoTaskButtonEntityDescription,
403+
) -> None:
404+
"""Initialize the Spino task button entity."""
405+
super().__init__(coordinator, entity_description.key)
406+
self.entity_description = entity_description
407+
self._attr_translation_key = "spino_task"
408+
# ``task_id`` mirrors the mower entity's attribute name so the
409+
# service resolution helper can read it generically. Stored as a
410+
# string for HA-attribute compatibility; the int form is exposed
411+
# via ``jobid`` for callers that prefer it.
412+
self._attr_extra_state_attributes = {
413+
"task_id": str(entity_description.jobid),
414+
"jobid": entity_description.jobid,
415+
}
416+
417+
def update_name(self, new_name: str) -> None:
418+
"""Update the display name when the plan's jobname changes."""
419+
self.entity_description = dataclass_replace(
420+
self.entity_description,
421+
name=new_name,
422+
translation_placeholders={"name": new_name},
423+
)
424+
if self.hass is not None:
425+
self.async_write_ha_state()
426+
427+
async def async_press(self) -> None:
428+
"""Refresh all Spino schedules from the device.
429+
430+
Spino has no per-schedule "execute now" command, so the press
431+
action triggers a full schedule re-sync (matching the spirit of
432+
the mower task button while staying within the proto we support).
433+
"""
434+
await self.entity_description.press_fn(
435+
self.coordinator, self.entity_description.jobid
436+
)
437+
438+
439+
def _update_spino_task_names(
440+
coordinator: MammotionSpinoCoordinator,
441+
added_tasks: set[int],
442+
task_entities_by_id: dict[int, MammotionSpinoTaskButtonEntity],
443+
) -> None:
444+
"""Rename Spino task button entities whose plan jobname has changed."""
445+
for jobid in added_tasks:
446+
plan: PoolPlan | None = coordinator.data.plans.get(jobid)
447+
if plan is None:
448+
continue
449+
entity = task_entities_by_id.get(jobid)
450+
if entity is None:
451+
continue
452+
if entity.entity_description.name != plan.jobname:
453+
entity.update_name(plan.jobname)
454+
455+
456+
@callback
457+
def async_add_spino_task_entities(
458+
coordinator: MammotionSpinoCoordinator,
459+
added_tasks: set[int],
460+
task_entities_by_id: dict[int, MammotionSpinoTaskButtonEntity],
461+
async_add_entities: AddEntitiesCallback,
462+
) -> None:
463+
"""Sync the per-schedule Spino task buttons against ``coordinator.data.plans``.
464+
465+
Mirror of :func:`async_add_task_entities` for the Spino path — adds a
466+
button when a new ``jobid`` appears, renames when ``jobname`` changes,
467+
and removes via the entity registry when a plan disappears.
468+
"""
469+
if coordinator.data is None:
470+
return
471+
472+
button_entities: list[MammotionSpinoTaskButtonEntity] = []
473+
current = set(coordinator.data.plans.keys())
474+
new_tasks = current - added_tasks
475+
476+
for jobid in new_tasks:
477+
plan = coordinator.data.plans.get(jobid)
478+
if plan is None:
479+
continue
480+
desc = MammotionSpinoTaskButtonEntityDescription(
481+
key=str(jobid),
482+
jobid=jobid,
483+
name=plan.jobname,
484+
translation_placeholders={"name": plan.jobname},
485+
press_fn=lambda coord, _jobid: coord.async_refresh_spino_tasks(),
486+
)
487+
entity = MammotionSpinoTaskButtonEntity(coordinator, desc)
488+
button_entities.append(entity)
489+
task_entities_by_id[jobid] = entity
490+
added_tasks.add(jobid)
491+
492+
_update_spino_task_names(coordinator, added_tasks, task_entities_by_id)
493+
494+
old_tasks = added_tasks - current
495+
if old_tasks:
496+
registry = er.async_get(coordinator.hass)
497+
for jobid in old_tasks:
498+
entity_id = registry.async_get_entity_id(
499+
BUTTON_DOMAIN, DOMAIN, f"{coordinator.device_name}_{jobid}"
500+
)
501+
if entity_id:
502+
registry.async_remove(entity_id)
503+
task_entities_by_id.pop(jobid, None)
504+
added_tasks -= old_tasks
505+
506+
if button_entities:
507+
async_add_entities(button_entities)
508+
509+
351510
class MammotionSpinoButtonEntity(MammotionBaseSpinoEntity, ButtonEntity):
352511
"""Mammotion Spino pool cleaner button entity."""
353512

0 commit comments

Comments
 (0)