Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
83fb1b8
Add French (fr) translation
ADNPolymerase Jun 27, 2026
a62e0f0
Use 'Sauvons les hérissons' for save_hedgehogs
ADNPolymerase Jun 27, 2026
2c62266
Shorten switch label to 'Coupe de bordure'
ADNPolymerase Jun 27, 2026
637e03f
Make entity names and sensor states translatable (en/pl/fr)
ADNPolymerase Jun 27, 2026
4695a8d
Add German (de) translation
ADNPolymerase Jun 27, 2026
fd5fe0c
Localize one-time mowing zone select (name + dynamic options)
ADNPolymerase Jun 27, 2026
5c28da6
Add next-schedule sensor, split area mowed into total + daily
ADNPolymerase Jun 29, 2026
5b207ba
Localize schedule day names and calendar event text by UI language
ADNPolymerase Jun 29, 2026
9a7d56a
Next schedule: prefer pyworxcloud's computed value
ADNPolymerase Jun 30, 2026
ec777b7
Use non-deprecated device_tracker imports
ADNPolymerase Jun 30, 2026
fc0869f
Docs: document new sensors, languages and mowed-area meaning
ADNPolymerase Jun 30, 2026
4004e4b
Give the primary mower entity no name (HA convention)
ADNPolymerase Jul 1, 2026
912a835
Docs: explain the primary entity naming change
ADNPolymerase Jul 1, 2026
16a6b52
Add Dutch, Spanish, Italian, Swedish, Norwegian and Danish translations
ADNPolymerase Jul 1, 2026
b133122
Docs: list all 10 supported languages
ADNPolymerase Jul 1, 2026
1d21aea
Fix: always_update=True so REST-enriched data actually reaches entities
ADNPolymerase Jul 1, 2026
94e4cf2
Log REST enrichment failures and results for diagnostics
ADNPolymerase Jul 1, 2026
00bf8d8
Add estimated area mowed today sensor (works around REST staleness)
ADNPolymerase Jul 1, 2026
0af35d4
Add periodic device refresh and estimated daily progress sensor
ADNPolymerase Jul 1, 2026
e9f3db8
Throttle Last update sensor to once per 24h (was every push)
ADNPolymerase Jul 1, 2026
b81b5e1
Track today's mowing time locally instead of Worx's work-time stats
ADNPolymerase Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ If this integration helps you, you can support Smart Service:
- RTK robot position as a `device_tracker`.
- Optional RTK address sensor using OpenStreetMap Nominatim reverse geocoding, disabled by default.
- Switches for Smart edge cutting, Save the hedgehogs and schedule edge procedure.
- Daily mowing progress, remaining progress, mowed area, lawn area and efficiency sensors when available from the API.
- Next mowing time sensor, daily and remaining progress, today and total mowed area, lawn area and mowing efficiency sensors when available from the API.
- Separate smart mowing automation blueprint repository.
- Polish and English translations.
- Translations: Polish, English, French, German, Dutch, Spanish, Italian, Swedish, Norwegian and Danish, including localized entity states, schedule and calendar.
- Optional raw payload entities for debugging, disabled by default.

## Installation With HACS
Expand Down Expand Up @@ -84,7 +84,7 @@ The exact entity list depends on what your mower reports. Typical entities inclu
- `calendar` mowing schedule
- `camera` RTK map
- `device_tracker` RTK robot position
- `sensor` battery, status, error, readiness, cloud connection, RSSI, schedule, rain delay, RTK map, RTK trail, daily progress, remaining progress, mowed area, runtime, efficiency and maintenance values
- `sensor` battery, status, error, readiness, cloud connection, RSSI, schedule, next schedule, rain delay, RTK map, RTK trail, daily progress, remaining progress, today and total mowed area, lawn area, runtime, efficiency and maintenance values
- `binary_sensor` online, IoT/MQTT registration, locked, rain, party mode and pause mode
- `switch` firmware auto update, mower lock, native schedule, Smart edge cutting, Save the hedgehogs and schedule edge procedure
- `number` rain delay, schedule time extension, lawn area and lawn perimeter
Expand Down Expand Up @@ -126,6 +126,14 @@ RTK maps and address lookups can contain precise garden geometry and coordinates

Before opening an issue, remove private data from logs and screenshots. See [SECURITY.md](SECURITY.md).

## Mowed area

The mower reports its mowing figures as **covered area** (the surface the blades pass over), not unique lawn area. Because a robot mows with overlapping passes, the **Today mowed area** and **Total area mowed** sensors can legitimately exceed your lawn size, and **Daily progress** reaches 100% once the covered area matches the lawn size. **Today mowed area** is derived from a local-midnight baseline and is rebuilt after a restart or a counter reset.

## Entity naming

The `lawn_mower` entity is the device's primary entity and has no name of its own: its displayed name is exactly the device name (e.g. just "Vision Cloud" instead of "Vision Cloud Mower"). This is both for readability and for compatibility with third-party cards such as [landroid-card](https://github.com/Barma-lej/landroid-card), which strip the device name from every other entity's label using the primary entity's name as the prefix; a redundant word there (like "Mower") previously prevented the prefix from matching.

## Limitations

The Worx / Positec cloud API is not officially public. Some endpoints used here are reverse-engineered and can change without notice. This is a best-effort custom integration, not official Worx software.
Expand Down
44 changes: 35 additions & 9 deletions custom_components/worx_vision_cloud/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,25 @@
get_dict_value,
schedule_day_index,
schedule_day_label,
schedule_language,
schedule_slots,
)

# Calendar event text is free-form and outside translations/*.json, so it is
# localized here from the UI language (falls back to English).
EVENT_SUMMARY = {
"en": "Mowing",
"de": "Mähen",
"fr": "Tonte",
"pl": "Koszenie trawnika",
}
EVENT_LABELS = {
"en": {"day": "Day", "duration": "Duration", "edge": "Edge cutting", "source": "Source", "yes": "yes"},
"de": {"day": "Tag", "duration": "Dauer", "edge": "Kantenschnitt", "source": "Quelle", "yes": "ja"},
"fr": {"day": "Jour", "duration": "Durée", "edge": "Coupe de bordure", "source": "Source", "yes": "oui"},
"pl": {"day": "Dzień", "duration": "Czas trwania", "edge": "Koszenie krawędzi", "source": "Źródło", "yes": "tak"},
}


async def async_setup_entry(
hass: HomeAssistant,
Expand All @@ -39,12 +55,18 @@ class WorxVisionScheduleCalendar(WorxVisionEntity, CalendarEntity):
"""Read-only mowing schedule calendar."""

_attr_icon = "mdi:calendar-clock"
_attr_name = "Harmonogram koszenia"
_attr_translation_key = "schedule"

def __init__(self, coordinator, entry, serial_number: str) -> None:
"""Initialize schedule calendar."""
super().__init__(coordinator, entry, serial_number, "schedule_calendar")

@property
def _language(self) -> str:
"""Return the active Home Assistant UI language."""
config = getattr(self.hass, "config", None)
return getattr(config, "language", None) or "en"

@property
def event(self) -> CalendarEvent | None:
"""Return the current or next scheduled mowing event."""
Expand Down Expand Up @@ -74,6 +96,7 @@ def _events_between(
) -> list[CalendarEvent]:
"""Build weekly schedule occurrences for the requested range."""
events: list[CalendarEvent] = []
language = self._language
tzinfo = start_date.tzinfo or dt_util.DEFAULT_TIME_ZONE
first_day = start_date.date() - dt.timedelta(days=1)
last_day = end_date.date() + dt.timedelta(days=1)
Expand All @@ -85,7 +108,7 @@ def _events_between(
if schedule_day_index(get_dict_value(slot, "day")) != current_day.weekday():
continue

event = _slot_to_event(slot, current_day, tzinfo)
event = _slot_to_event(slot, current_day, tzinfo, language)
if event is None:
continue
if event.end <= start_date or event.start >= end_date:
Expand All @@ -99,8 +122,9 @@ def _slot_to_event(
slot: Any,
event_date: dt.date,
tzinfo: dt.tzinfo,
language: str = "en",
) -> CalendarEvent | None:
"""Convert one schedule slot to a calendar event occurrence."""
"""Convert one schedule slot to a localized calendar event occurrence."""
start_time = _parse_time(get_dict_value(slot, "start"))
if start_time is None:
return None
Expand All @@ -117,21 +141,23 @@ def _slot_to_event(
return None
end = start + dt.timedelta(minutes=duration)

day_label = schedule_day_label(get_dict_value(slot, "day"))
lang = schedule_language(language)
labels = EVENT_LABELS[lang]
day_label = schedule_day_label(get_dict_value(slot, "day"), lang)
duration = _duration_minutes(slot)
description_parts = [f"Dzien: {day_label}"]
description_parts = [f"{labels['day']}: {day_label}"]
if duration is not None:
description_parts.append(f"Czas trwania: {duration} min")
description_parts.append(f"{labels['duration']}: {duration} min")
if get_dict_value(slot, "boundary"):
description_parts.append("Koszenie krawedzi: tak")
description_parts.append(f"{labels['edge']}: {labels['yes']}")
source = get_dict_value(slot, "source")
if source is not None:
description_parts.append(f"Zrodlo: {source}")
description_parts.append(f"{labels['source']}: {source}")

return CalendarEvent(
start=start,
end=end,
summary="Koszenie trawnika",
summary=EVENT_SUMMARY[lang],
description="\n".join(description_parts),
)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/worx_vision_cloud/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class WorxVisionMapCamera(WorxVisionEntity, Camera):
"""RTK map rendered from Worx map geometry."""

_attr_icon = "mdi:map"
_attr_name = "Mapa RTK"
_attr_translation_key = "rtk_map_camera"

def __init__(self, coordinator, entry, serial_number: str) -> None:
"""Initialize RTK map camera."""
Expand Down
59 changes: 56 additions & 3 deletions custom_components/worx_vision_cloud/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
from datetime import UTC, datetime, timedelta
import json
import logging
from typing import Any
from typing import Any, Callable

from aiohttp import ClientError, ClientTimeout

from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from pyworxcloud import DeviceHandler, LandroidEvent, WorxCloud
Expand All @@ -32,6 +33,7 @@
"(https://github.com/SmartServicePL/Worx-Vision-Cloud-PLUS)"
)
PRODUCT_ITEM_CACHE_TTL = timedelta(minutes=5)
LIVE_REFRESH_INTERVAL = timedelta(minutes=5)
FIRMWARE_UPGRADE_CACHE_TTL = timedelta(minutes=30)
RTK_TRAIL_MAX_POINTS = 300
DEFAULT_ONE_TIME_MOWING_RUNTIME = 60
Expand Down Expand Up @@ -68,7 +70,13 @@ def __init__(self, hass: HomeAssistant, cloud: WorxCloud) -> None:
_LOGGER,
name=DOMAIN,
update_interval=None,
always_update=False,
# `_device_map()` returns the same DeviceHandler instances pyworxcloud
# already holds, and `_enrich_device()` mutates them in place (e.g. the
# product-item area_mowed figure). With always_update=False the
# coordinator compares data by equality, which is always True here
# (same object references), so it silently skips notifying entities
# even when the mutated attributes actually changed.
always_update=True,
)
self.cloud = cloud
self._event_lock = asyncio.Lock()
Expand All @@ -84,6 +92,7 @@ def __init__(self, hass: HomeAssistant, cloud: WorxCloud) -> None:
str, deque[tuple[datetime, float, float]]
] = {}
self._one_time_mowing_options: dict[str, dict[str, Any]] = {}
self._unsub_periodic_refresh: Callable[[], None] | None = None

async def async_setup(self) -> None:
"""Attach pyworxcloud callbacks."""
Expand All @@ -98,10 +107,37 @@ def _on_api_update(api_data: dict[str, Any], **_: Any) -> None:
self.cloud.set_callback(LandroidEvent.DATA_RECEIVED, _on_data_received)
self.cloud.set_callback(LandroidEvent.API, _on_api_update)

self._unsub_periodic_refresh = async_track_time_interval(
self.hass, self._async_periodic_device_refresh, LIVE_REFRESH_INTERVAL
)

async def async_shutdown(self) -> None:
"""Detach callbacks."""
self.cloud.set_callback(LandroidEvent.DATA_RECEIVED, lambda **_: None)
self.cloud.set_callback(LandroidEvent.API, lambda **_: None)
if self._unsub_periodic_refresh is not None:
self._unsub_periodic_refresh()
self._unsub_periodic_refresh = None

async def _async_periodic_device_refresh(self, _now: datetime) -> None:
"""Ask each mower for a fresh update on a fixed cadence.

Some pyworxcloud data (e.g. work-time statistics used by the daily
progress/area sensors) is only included in the mower's MQTT payload
when it responds to an explicit update request, not on every routine
push. Relying solely on push events or the sporadic LandroidEvent.API
callback can leave those figures stale for hours during active
mowing, so ask every known device to report in on this interval.
"""
for serial_number in list((self.data or {}).keys()):
try:
await self.async_request_device_update(serial_number)
except Exception: # noqa: BLE001
_LOGGER.debug(
"Periodic refresh failed for device %s",
serial_number,
exc_info=True,
)

async def _handle_push_update(self, device: DeviceHandler) -> None:
"""Merge one pushed device update."""
Expand All @@ -119,10 +155,18 @@ async def _handle_push_update(self, device: DeviceHandler) -> None:
async def _refresh_from_cloud_cache(self) -> dict[str, DeviceHandler]:
"""Return current cloud cache."""
devices = _device_map(self.cloud)
await asyncio.gather(
results = await asyncio.gather(
*(self._enrich_device(serial, device) for serial, device in devices.items()),
return_exceptions=True,
)
for serial, result in zip(devices, results):
if isinstance(result, Exception):
_LOGGER.warning(
"Failed to enrich device %s with REST API data (area mowed, "
"firmware, RTK map may be stale): %s",
serial,
result,
)
return devices

async def _async_update_data(self) -> dict[str, DeviceHandler]:
Expand Down Expand Up @@ -751,6 +795,15 @@ async def _enrich_device(self, serial_number: str, device: DeviceHandler) -> Non
product_item = await self.async_get_product_item(serial_number)
if product_item is not None:
setattr(device, "_worx_vision_product_item", product_item)
_LOGGER.debug(
"Enriched device %s: area_mowed=%s",
serial_number,
product_item.get("area_mowed"),
)
else:
_LOGGER.debug(
"No product item data returned for device %s", serial_number
)

firmware_info = await self.async_get_firmware_upgrade_info(serial_number)
if firmware_info is not None:
Expand Down
5 changes: 2 additions & 3 deletions custom_components/worx_vision_cloud/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

from typing import Any

from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.components.device_tracker.const import SourceType
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
Expand Down Expand Up @@ -33,7 +32,7 @@ class WorxVisionLocationTracker(WorxVisionEntity, TrackerEntity):
"""GPS/RTK location tracker for one mower."""

_attr_icon = "mdi:map-marker-radius-outline"
_attr_name = "Pozycja RTK"
_attr_translation_key = "rtk_position"
_attr_source_type = SourceType.GPS

def __init__(self, coordinator, entry, serial_number: str) -> None:
Expand Down
Loading