From 83fb1b8bbc4cf341940606c2c91bea7428389824 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:48:06 +0200 Subject: [PATCH 01/21] Add French (fr) translation --- .../worx_vision_cloud/translations/fr.json | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 custom_components/worx_vision_cloud/translations/fr.json diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json new file mode 100644 index 0000000..622d072 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -0,0 +1,261 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Utilisez les mêmes identifiants que dans l'application Worx Landroid.", + "data": { + "email": "Adresse e-mail", + "password": "Mot de passe", + "cloud": "Cloud", + "verify_ssl": "Vérifier le certificat SSL", + "expose_raw_entities": "Exposer tous les champs bruts du payload en tant qu'entités" + } + } + }, + "error": { + "cannot_connect": "Impossible de se connecter au Cloud Worx.", + "invalid_auth": "Identifiant ou mot de passe incorrect.", + "rate_limited": "Limite de requêtes du Cloud Worx atteinte. Réessayez plus tard." + }, + "abort": { + "already_configured": "Ce compte est déjà configuré." + } + }, + "entity": { + "lawn_mower": { + "mower": { + "name": "Tondeuse" + } + }, + "sensor": { + "battery_percent": { + "name": "Batterie" + }, + "status": { + "name": "État" + }, + "error": { + "name": "Erreur" + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Zone actuelle" + }, + "schedule": { + "name": "Programme" + }, + "mowing_readiness": { + "name": "Aptitude à la tonte" + }, + "cloud_connection": { + "name": "Connexion au cloud" + }, + "api_capabilities": { + "name": "Capacités de l'API" + }, + "push_notifications": { + "name": "Notifications push" + }, + "daily_progress": { + "name": "Progression quotidienne" + }, + "remaining_progress": { + "name": "Reste à tondre" + }, + "area_mowed_today": { + "name": "Surface tondue aujourd'hui" + }, + "lawn_area": { + "name": "Surface de la pelouse" + }, + "mowing_efficiency": { + "name": "Efficacité de tonte" + }, + "rtk_map": { + "name": "Carte RTK" + }, + "rtk_trail_points": { + "name": "Points de trace RTK" + }, + "rtk_address": { + "name": "Adresse RTK" + }, + "rain_delay": { + "name": "Délai pluie" + }, + "rain_remaining": { + "name": "Délai pluie restant" + }, + "battery_voltage": { + "name": "Tension de la batterie" + }, + "battery_temperature": { + "name": "Température de la batterie" + }, + "battery_cycles_total": { + "name": "Cycles de batterie (total)" + }, + "battery_cycles_since_reset": { + "name": "Cycles de batterie depuis réinitialisation" + }, + "battery_cycles_reset_at": { + "name": "Dernière réinitialisation des cycles de batterie" + }, + "blade_runtime_total": { + "name": "Temps de fonctionnement des lames (total)" + }, + "blade_runtime_current": { + "name": "Temps de fonctionnement des lames (actuel)" + }, + "blade_runtime_reset_at": { + "name": "Dernière réinitialisation du temps des lames" + }, + "mower_runtime_total": { + "name": "Temps de fonctionnement total" + }, + "mower_home_time_total": { + "name": "Temps total en station" + }, + "mower_charging_time_total": { + "name": "Temps de charge total" + }, + "mower_error_time_total": { + "name": "Temps total en erreur" + }, + "maintenance_status": { + "name": "État de maintenance" + }, + "pitch": { + "name": "Tangage" + }, + "roll": { + "name": "Roulis" + }, + "yaw": { + "name": "Lacet" + }, + "last_update": { + "name": "Dernière mise à jour" + }, + "last_update_age": { + "name": "Ancienneté de la dernière mise à jour" + } + }, + "binary_sensor": { + "online": { + "name": "En ligne" + }, + "iot_registered": { + "name": "IoT enregistré" + }, + "mqtt_registered": { + "name": "MQTT enregistré" + }, + "locked": { + "name": "Verrouillé" + }, + "rain_triggered": { + "name": "Pluie détectée" + }, + "robot_lifted": { + "name": "Robot soulevé" + }, + "off_limits_enabled": { + "name": "Zones interdites activées" + }, + "acs_enabled": { + "name": "ACS activé" + }, + "party_mode_enabled": { + "name": "Mode festif activé" + }, + "pause_mode_enabled": { + "name": "Mode pause activé" + }, + "smart_edge_cut": { + "name": "Coupe de bordure intelligente" + }, + "save_hedgehogs": { + "name": "Mode hérissons" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Mise à jour automatique du firmware" + }, + "lock": { + "name": "Verrouillage" + }, + "native_schedule": { + "name": "Programme natif" + }, + "smart_edge_cut": { + "name": "Coupe de bordure intelligente" + }, + "save_hedgehogs": { + "name": "Mode hérissons" + }, + "one_time_mowing_edge_cut": { + "name": "Coupe de bordure (tonte unique)" + } + }, + "button": { + "refresh": { + "name": "Actualiser" + }, + "reset_blade_counter": { + "name": "Réinitialiser le temps des lames" + }, + "reset_battery_cycle_counter": { + "name": "Réinitialiser les cycles de batterie" + }, + "start_edge_cut": { + "name": "Démarrer la coupe de bordure" + }, + "start_one_time_mowing": { + "name": "Démarrer la tonte unique" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Délai pluie" + }, + "time_extension": { + "name": "Prolongation de durée" + }, + "lawn_area": { + "name": "Surface de la pelouse" + }, + "lawn_perimeter": { + "name": "Périmètre de la pelouse" + }, + "one_time_mowing_runtime": { + "name": "Durée de la tonte unique" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Zones de la tonte unique" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "Carte RTK" + } + }, + "calendar": { + "schedule": { + "name": "Programme de tonte" + } + } + } +} From a62e0f05837b2f7a86254b0ff49dfe2913dfc9c0 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:05:19 +0200 Subject: [PATCH 02/21] =?UTF-8?q?Use=20'Sauvons=20les=20h=C3=A9rissons'=20?= =?UTF-8?q?for=20save=5Fhedgehogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/worx_vision_cloud/translations/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index 622d072..f255237 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -180,7 +180,7 @@ "name": "Coupe de bordure intelligente" }, "save_hedgehogs": { - "name": "Mode hérissons" + "name": "Sauvons les hérissons" } }, "switch": { @@ -197,7 +197,7 @@ "name": "Coupe de bordure intelligente" }, "save_hedgehogs": { - "name": "Mode hérissons" + "name": "Sauvons les hérissons" }, "one_time_mowing_edge_cut": { "name": "Coupe de bordure (tonte unique)" From 2c6226624c138048e4165f77919e626c9ab2e112 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:06:24 +0200 Subject: [PATCH 03/21] Shorten switch label to 'Coupe de bordure' --- custom_components/worx_vision_cloud/translations/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index f255237..81419b3 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -200,7 +200,7 @@ "name": "Sauvons les hérissons" }, "one_time_mowing_edge_cut": { - "name": "Coupe de bordure (tonte unique)" + "name": "Coupe de bordure" } }, "button": { From 637e03f82eec18886b16ab048cb4d654dacdd263 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:15:46 +0200 Subject: [PATCH 04/21] Make entity names and sensor states translatable (en/pl/fr) - Replace hardcoded Polish _attr_name with translation_key on the calendar, RTK map camera and RTK position device_tracker entities - Add the missing device_tracker translation section (en/pl/fr) - Convert status, error, mowing_readiness, cloud_connection and maintenance_status to enum sensors that emit canonical state keys, with localized labels in translations/*.json (Polish wording preserved) - Replace remaining hardcoded Polish in the free-text schedule summary and day labels with neutral English (HA cannot translate free text) --- .../worx_vision_cloud/calendar.py | 2 +- custom_components/worx_vision_cloud/camera.py | 2 +- .../worx_vision_cloud/device_tracker.py | 2 +- .../worx_vision_cloud/helpers.py | 20 ++-- custom_components/worx_vision_cloud/sensor.py | 108 ++++++++++++------ .../worx_vision_cloud/translations/en.json | 67 ++++++++++- .../worx_vision_cloud/translations/fr.json | 67 ++++++++++- .../worx_vision_cloud/translations/pl.json | 67 ++++++++++- 8 files changed, 271 insertions(+), 64 deletions(-) diff --git a/custom_components/worx_vision_cloud/calendar.py b/custom_components/worx_vision_cloud/calendar.py index d7d3297..70fd988 100644 --- a/custom_components/worx_vision_cloud/calendar.py +++ b/custom_components/worx_vision_cloud/calendar.py @@ -39,7 +39,7 @@ 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.""" diff --git a/custom_components/worx_vision_cloud/camera.py b/custom_components/worx_vision_cloud/camera.py index 77bf7f1..452422b 100644 --- a/custom_components/worx_vision_cloud/camera.py +++ b/custom_components/worx_vision_cloud/camera.py @@ -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.""" diff --git a/custom_components/worx_vision_cloud/device_tracker.py b/custom_components/worx_vision_cloud/device_tracker.py index f47a043..86486b3 100644 --- a/custom_components/worx_vision_cloud/device_tracker.py +++ b/custom_components/worx_vision_cloud/device_tracker.py @@ -33,7 +33,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: diff --git a/custom_components/worx_vision_cloud/helpers.py b/custom_components/worx_vision_cloud/helpers.py index e3cf90d..cd9190d 100644 --- a/custom_components/worx_vision_cloud/helpers.py +++ b/custom_components/worx_vision_cloud/helpers.py @@ -49,13 +49,13 @@ } SCHEDULE_DAY_LABELS = { - "monday": "pon", - "tuesday": "wt", - "wednesday": "sr", - "thursday": "czw", - "friday": "pt", - "saturday": "sob", - "sunday": "niedz", + "monday": "Mon", + "tuesday": "Tue", + "wednesday": "Wed", + "thursday": "Thu", + "friday": "Fri", + "saturday": "Sat", + "sunday": "Sun", } SCHEDULE_DAY_INDEX = { @@ -393,7 +393,7 @@ def schedule_slot_summary(slot: Any) -> str: text = day or "slot" if get_dict_value(slot, "boundary"): - text = f"{text} + krawedz" + text = f"{text} + edge" return text @@ -401,12 +401,12 @@ def schedule_summary(device: Any) -> str | None: """Return a compact schedule summary for Home Assistant state.""" slots = schedule_slots(device) if not slots: - return "brak aktywnych slotow" + return "no active slots" summary = ", ".join(schedule_slot_summary(slot) for slot in slots) if len(summary) <= MAX_STRING_STATE_LENGTH: return summary - return f"{len(slots)} aktywnych slotow" + return f"{len(slots)} active slots" def schedule_attributes(device: Any) -> dict[str, Any]: diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index 312fff5..f4cf65c 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -58,47 +58,71 @@ class WorxSensorDescription(SensorEntityDescription): attrs_fn: Callable[[Any], dict[str, Any] | None] | None = None -STATUS_LABELS_PL = { - "home": "w bazie", - "leaving home": "wyjazd z bazy", - "going home": "powrót do bazy", - "mowing": "koszenie", - "cutting edge": "przycinanie krawędzi", - "edge cutting": "przycinanie krawędzi", - "border cut": "przycinanie krawędzi", - "charging": "ładowanie", - "paused": "pauza", - "pause": "pauza", - "idle": "bezczynna", - "manual stop": "zatrzymana ręcznie", - "rain delay": "opóźnienie po deszczu", - "rain_delay": "opóźnienie po deszczu", - "locked": "zablokowana", - "error": "błąd", - "no error": "brak błędu", +# Map the raw descriptions reported by Worx to canonical, language-neutral state +# keys. The human-readable labels live in translations/*.json so Home Assistant can +# localize them per user (en/pl/fr/...), instead of being hard-coded here. +STATUS_STATE_KEYS = { + "home": "home", + "leaving home": "leaving_home", + "going home": "going_home", + "mowing": "mowing", + "cutting edge": "edge_cutting", + "edge cutting": "edge_cutting", + "border cut": "edge_cutting", + "charging": "charging", + "paused": "paused", + "pause": "paused", + "idle": "idle", + "manual stop": "manual_stop", + "rain delay": "rain_delay", + "rain_delay": "rain_delay", + "locked": "locked", + "error": "error", + "no error": "no_error", "offline": "offline", } -READINESS_LABELS_PL = { - "ready": "gotowa", - "mowing": "koszenie", - "charging": "ładowanie", - "battery_low": "niski poziom baterii", - "rain_delay": "opóźnienie po deszczu", - "error": "błąd", - "locked": "zablokowana", - "offline": "offline", -} +# Canonical option lists exposed as enum sensor states. +STATUS_STATE_OPTIONS = [ + "home", + "leaving_home", + "going_home", + "mowing", + "edge_cutting", + "charging", + "paused", + "idle", + "manual_stop", + "rain_delay", + "locked", + "error", + "no_error", + "offline", +] + +READINESS_STATE_OPTIONS = [ + "ready", + "mowing", + "charging", + "battery_low", + "rain_delay", + "error", + "locked", + "offline", +] + +CLOUD_CONNECTION_OPTIONS = ["ok", "check", "offline"] + +MAINTENANCE_STATE_OPTIONS = ["ok", "blade_service_due", "battery_service_due"] RAIN_DELAY_ERROR_DESCRIPTIONS = {"rain delay", "rain_delay"} -def _label_pl(value: Any, labels: dict[str, str]) -> str | None: - """Return a Polish label for a known Worx state.""" +def _state_key(value: Any, mapping: dict[str, str]) -> str | None: + """Map a raw Worx description to a canonical, translatable state key.""" if value is None: return None - text = str(value) - return labels.get(text.strip().lower(), text) + return mapping.get(str(value).strip().lower()) def _battery(device, key, default=None): @@ -123,8 +147,8 @@ def _status(device, key, default=None): def _status_state(device) -> str | None: if _is_rain_delay(device): - return _label_pl("rain_delay", READINESS_LABELS_PL) - return _label_pl(_status(device, "description"), STATUS_LABELS_PL) + return "rain_delay" + return _state_key(_status(device, "description"), STATUS_STATE_KEYS) def _error(device, key, default=None): @@ -132,7 +156,9 @@ def _error(device, key, default=None): def _error_state(device) -> str | None: - return _label_pl(_error(device, "description"), STATUS_LABELS_PL) + # Unmapped/rare device error descriptions surface via the raw_description + # attribute; the enum state stays None to avoid noisy non-option warnings. + return _state_key(_error(device, "description"), STATUS_STATE_KEYS) def _is_rain_delay(device) -> bool: @@ -394,7 +420,7 @@ def _mowing_readiness_code(device) -> str | None: def _mowing_readiness_state(device) -> str | None: - return _label_pl(_mowing_readiness_code(device), READINESS_LABELS_PL) + return _mowing_readiness_code(device) def _mowing_readiness_attributes(device) -> dict[str, Any]: @@ -587,6 +613,8 @@ def _rtk_address_attributes( key="status", translation_key="status", icon="mdi:robot-mower", + device_class=SensorDeviceClass.ENUM, + options=STATUS_STATE_OPTIONS, value_fn=_status_state, attrs_fn=_status_attributes, ), @@ -595,6 +623,8 @@ def _rtk_address_attributes( translation_key="error", icon="mdi:alert-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=STATUS_STATE_OPTIONS, value_fn=_error_state, attrs_fn=lambda d: { "id": _error(d, "id"), @@ -633,6 +663,8 @@ def _rtk_address_attributes( key="mowing_readiness", translation_key="mowing_readiness", icon="mdi:clipboard-check-outline", + device_class=SensorDeviceClass.ENUM, + options=READINESS_STATE_OPTIONS, value_fn=_mowing_readiness_state, attrs_fn=_mowing_readiness_attributes, ), @@ -641,6 +673,8 @@ def _rtk_address_attributes( translation_key="cloud_connection", icon="mdi:cloud-check-outline", entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=CLOUD_CONNECTION_OPTIONS, value_fn=_cloud_connection_state, attrs_fn=_cloud_connection_attributes, ), @@ -872,6 +906,8 @@ def _rtk_address_attributes( translation_key="maintenance_status", icon="mdi:wrench-clock", entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=MAINTENANCE_STATE_OPTIONS, value_fn=_maintenance_state, attrs_fn=_maintenance_attributes, ), diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index d878c30..97aba7e 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -34,10 +34,42 @@ "name": "Battery" }, "status": { - "name": "Status" + "name": "Status", + "state": { + "home": "Docked", + "leaving_home": "Leaving base", + "going_home": "Returning to base", + "mowing": "Mowing", + "edge_cutting": "Edge cutting", + "charging": "Charging", + "paused": "Paused", + "idle": "Idle", + "manual_stop": "Manual stop", + "rain_delay": "Rain delay", + "locked": "Locked", + "error": "Error", + "no_error": "No error", + "offline": "Offline" + } }, "error": { - "name": "Error" + "name": "Error", + "state": { + "home": "Docked", + "leaving_home": "Leaving base", + "going_home": "Returning to base", + "mowing": "Mowing", + "edge_cutting": "Edge cutting", + "charging": "Charging", + "paused": "Paused", + "idle": "Idle", + "manual_stop": "Manual stop", + "rain_delay": "Rain delay", + "locked": "Locked", + "error": "Error", + "no_error": "No error", + "offline": "Offline" + } }, "rssi": { "name": "RSSI" @@ -49,10 +81,25 @@ "name": "Schedule" }, "mowing_readiness": { - "name": "Mowing readiness" + "name": "Mowing readiness", + "state": { + "ready": "Ready", + "mowing": "Mowing", + "charging": "Charging", + "battery_low": "Battery low", + "rain_delay": "Rain delay", + "error": "Error", + "locked": "Locked", + "offline": "Offline" + } }, "cloud_connection": { - "name": "Cloud connection" + "name": "Cloud connection", + "state": { + "ok": "Connected", + "check": "Checking", + "offline": "Offline" + } }, "api_capabilities": { "name": "API capabilities" @@ -127,7 +174,12 @@ "name": "Mower error time total" }, "maintenance_status": { - "name": "Maintenance status" + "name": "Maintenance status", + "state": { + "ok": "OK", + "blade_service_due": "Blade service due", + "battery_service_due": "Battery service due" + } }, "pitch": { "name": "Pitch" @@ -256,6 +308,11 @@ "schedule": { "name": "Mowing schedule" } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK position" + } } } } diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index 81419b3..ff0851e 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -34,10 +34,42 @@ "name": "Batterie" }, "status": { - "name": "État" + "name": "État", + "state": { + "home": "En station", + "leaving_home": "Sortie de la base", + "going_home": "Retour à la base", + "mowing": "En train de tondre", + "edge_cutting": "Coupe de bordure", + "charging": "En charge", + "paused": "En pause", + "idle": "Inactive", + "manual_stop": "Arrêt manuel", + "rain_delay": "Délai pluie", + "locked": "Verrouillée", + "error": "Erreur", + "no_error": "Aucune erreur", + "offline": "Hors ligne" + } }, "error": { - "name": "Erreur" + "name": "Erreur", + "state": { + "home": "En station", + "leaving_home": "Sortie de la base", + "going_home": "Retour à la base", + "mowing": "En train de tondre", + "edge_cutting": "Coupe de bordure", + "charging": "En charge", + "paused": "En pause", + "idle": "Inactive", + "manual_stop": "Arrêt manuel", + "rain_delay": "Délai pluie", + "locked": "Verrouillée", + "error": "Erreur", + "no_error": "Aucune erreur", + "offline": "Hors ligne" + } }, "rssi": { "name": "RSSI" @@ -49,10 +81,25 @@ "name": "Programme" }, "mowing_readiness": { - "name": "Aptitude à la tonte" + "name": "Aptitude à la tonte", + "state": { + "ready": "Prête", + "mowing": "En train de tondre", + "charging": "En charge", + "battery_low": "Batterie faible", + "rain_delay": "Délai pluie", + "error": "Erreur", + "locked": "Verrouillée", + "offline": "Hors ligne" + } }, "cloud_connection": { - "name": "Connexion au cloud" + "name": "Connexion au cloud", + "state": { + "ok": "Connecté", + "check": "Vérification", + "offline": "Hors ligne" + } }, "api_capabilities": { "name": "Capacités de l'API" @@ -127,7 +174,12 @@ "name": "Temps total en erreur" }, "maintenance_status": { - "name": "État de maintenance" + "name": "État de maintenance", + "state": { + "ok": "OK", + "blade_service_due": "Entretien des lames requis", + "battery_service_due": "Entretien de la batterie requis" + } }, "pitch": { "name": "Tangage" @@ -256,6 +308,11 @@ "schedule": { "name": "Programme de tonte" } + }, + "device_tracker": { + "rtk_position": { + "name": "Position RTK" + } } } } diff --git a/custom_components/worx_vision_cloud/translations/pl.json b/custom_components/worx_vision_cloud/translations/pl.json index fdf657e..2ab88c0 100644 --- a/custom_components/worx_vision_cloud/translations/pl.json +++ b/custom_components/worx_vision_cloud/translations/pl.json @@ -34,10 +34,42 @@ "name": "Bateria" }, "status": { - "name": "Status" + "name": "Status", + "state": { + "home": "w bazie", + "leaving_home": "wyjazd z bazy", + "going_home": "powrót do bazy", + "mowing": "koszenie", + "edge_cutting": "przycinanie krawędzi", + "charging": "ładowanie", + "paused": "pauza", + "idle": "bezczynna", + "manual_stop": "zatrzymana ręcznie", + "rain_delay": "opóźnienie po deszczu", + "locked": "zablokowana", + "error": "błąd", + "no_error": "brak błędu", + "offline": "offline" + } }, "error": { - "name": "Błąd" + "name": "Błąd", + "state": { + "home": "w bazie", + "leaving_home": "wyjazd z bazy", + "going_home": "powrót do bazy", + "mowing": "koszenie", + "edge_cutting": "przycinanie krawędzi", + "charging": "ładowanie", + "paused": "pauza", + "idle": "bezczynna", + "manual_stop": "zatrzymana ręcznie", + "rain_delay": "opóźnienie po deszczu", + "locked": "zablokowana", + "error": "błąd", + "no_error": "brak błędu", + "offline": "offline" + } }, "rssi": { "name": "RSSI" @@ -49,10 +81,25 @@ "name": "Harmonogram" }, "mowing_readiness": { - "name": "Gotowość do koszenia" + "name": "Gotowość do koszenia", + "state": { + "ready": "gotowa", + "mowing": "koszenie", + "charging": "ładowanie", + "battery_low": "niski poziom baterii", + "rain_delay": "opóźnienie po deszczu", + "error": "błąd", + "locked": "zablokowana", + "offline": "offline" + } }, "cloud_connection": { - "name": "Połączenie z chmurą" + "name": "Połączenie z chmurą", + "state": { + "ok": "Połączono", + "check": "Sprawdzanie", + "offline": "Offline" + } }, "api_capabilities": { "name": "Możliwości API" @@ -127,7 +174,12 @@ "name": "Czas błędów razem" }, "maintenance_status": { - "name": "Status serwisowy" + "name": "Status serwisowy", + "state": { + "ok": "OK", + "blade_service_due": "Wymiana ostrzy wymagana", + "battery_service_due": "Serwis baterii wymagany" + } }, "pitch": { "name": "Nachylenie X" @@ -256,6 +308,11 @@ "schedule": { "name": "Harmonogram koszenia" } + }, + "device_tracker": { + "rtk_position": { + "name": "Pozycja RTK" + } } } } From 4695a8d9a5d0b02bd07a0825ddbe7b44cb21c346 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:56:32 +0200 Subject: [PATCH 05/21] Add German (de) translation --- .../worx_vision_cloud/translations/de.json | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 custom_components/worx_vision_cloud/translations/de.json diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json new file mode 100644 index 0000000..5d089e9 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -0,0 +1,318 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Verwenden Sie dieselben Anmeldedaten wie in der Worx-Landroid-App.", + "data": { + "email": "E-Mail-Adresse", + "password": "Passwort", + "cloud": "Cloud", + "verify_ssl": "SSL-Zertifikat überprüfen", + "expose_raw_entities": "Alle Roh-Payload-Felder als Entitäten bereitstellen" + } + } + }, + "error": { + "cannot_connect": "Verbindung zur Worx Cloud fehlgeschlagen.", + "invalid_auth": "Ungültiger Benutzername oder ungültiges Passwort.", + "rate_limited": "Anfragelimit der Worx Cloud erreicht. Bitte später erneut versuchen." + }, + "abort": { + "already_configured": "Dieses Konto ist bereits konfiguriert." + } + }, + "entity": { + "lawn_mower": { + "mower": { + "name": "Mähroboter" + } + }, + "sensor": { + "battery_percent": { + "name": "Akku" + }, + "status": { + "name": "Status", + "state": { + "home": "In der Station", + "leaving_home": "Verlässt die Station", + "going_home": "Rückkehr zur Station", + "mowing": "Mäht", + "edge_cutting": "Kantenschnitt", + "charging": "Lädt", + "paused": "Pausiert", + "idle": "Inaktiv", + "manual_stop": "Manuell gestoppt", + "rain_delay": "Regenverzögerung", + "locked": "Gesperrt", + "error": "Fehler", + "no_error": "Kein Fehler", + "offline": "Offline" + } + }, + "error": { + "name": "Fehler", + "state": { + "home": "In der Station", + "leaving_home": "Verlässt die Station", + "going_home": "Rückkehr zur Station", + "mowing": "Mäht", + "edge_cutting": "Kantenschnitt", + "charging": "Lädt", + "paused": "Pausiert", + "idle": "Inaktiv", + "manual_stop": "Manuell gestoppt", + "rain_delay": "Regenverzögerung", + "locked": "Gesperrt", + "error": "Fehler", + "no_error": "Kein Fehler", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Aktuelle Zone" + }, + "schedule": { + "name": "Zeitplan" + }, + "mowing_readiness": { + "name": "Mähbereitschaft", + "state": { + "ready": "Bereit", + "mowing": "Mäht", + "charging": "Lädt", + "battery_low": "Akku niedrig", + "rain_delay": "Regenverzögerung", + "error": "Fehler", + "locked": "Gesperrt", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Cloud-Verbindung", + "state": { + "ok": "Verbunden", + "check": "Wird geprüft", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "API-Funktionen" + }, + "push_notifications": { + "name": "Push-Benachrichtigungen" + }, + "daily_progress": { + "name": "Tagesfortschritt" + }, + "remaining_progress": { + "name": "Verbleibend zu mähen" + }, + "area_mowed_today": { + "name": "Heute gemähte Fläche" + }, + "lawn_area": { + "name": "Rasenfläche" + }, + "mowing_efficiency": { + "name": "Mäheffizienz" + }, + "rtk_map": { + "name": "RTK-Karte" + }, + "rtk_trail_points": { + "name": "RTK-Spurpunkte" + }, + "rtk_address": { + "name": "RTK-Adresse" + }, + "rain_delay": { + "name": "Regenverzögerung" + }, + "rain_remaining": { + "name": "Verbleibende Regenverzögerung" + }, + "battery_voltage": { + "name": "Akkuspannung" + }, + "battery_temperature": { + "name": "Akkutemperatur" + }, + "battery_cycles_total": { + "name": "Akkuzyklen (gesamt)" + }, + "battery_cycles_since_reset": { + "name": "Akkuzyklen seit Zurücksetzen" + }, + "battery_cycles_reset_at": { + "name": "Letztes Zurücksetzen der Akkuzyklen" + }, + "blade_runtime_total": { + "name": "Messer-Laufzeit (gesamt)" + }, + "blade_runtime_current": { + "name": "Messer-Laufzeit (aktuell)" + }, + "blade_runtime_reset_at": { + "name": "Letztes Zurücksetzen der Messer-Laufzeit" + }, + "mower_runtime_total": { + "name": "Gesamtlaufzeit" + }, + "mower_home_time_total": { + "name": "Gesamtzeit in der Station" + }, + "mower_charging_time_total": { + "name": "Gesamtladezeit" + }, + "mower_error_time_total": { + "name": "Gesamtzeit im Fehlerzustand" + }, + "maintenance_status": { + "name": "Wartungsstatus", + "state": { + "ok": "OK", + "blade_service_due": "Messerwartung fällig", + "battery_service_due": "Akkuwartung fällig" + } + }, + "pitch": { + "name": "Nicken" + }, + "roll": { + "name": "Rollen" + }, + "yaw": { + "name": "Gieren" + }, + "last_update": { + "name": "Letzte Aktualisierung" + }, + "last_update_age": { + "name": "Alter der letzten Aktualisierung" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT registriert" + }, + "mqtt_registered": { + "name": "MQTT registriert" + }, + "locked": { + "name": "Gesperrt" + }, + "rain_triggered": { + "name": "Regen erkannt" + }, + "robot_lifted": { + "name": "Roboter angehoben" + }, + "off_limits_enabled": { + "name": "Sperrzonen aktiviert" + }, + "acs_enabled": { + "name": "ACS aktiviert" + }, + "party_mode_enabled": { + "name": "Partymodus aktiviert" + }, + "pause_mode_enabled": { + "name": "Pausenmodus aktiviert" + }, + "smart_edge_cut": { + "name": "Intelligenter Kantenschnitt" + }, + "save_hedgehogs": { + "name": "Igel retten" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Firmware automatisch aktualisieren" + }, + "lock": { + "name": "Sperren" + }, + "native_schedule": { + "name": "Nativer Zeitplan" + }, + "smart_edge_cut": { + "name": "Intelligenter Kantenschnitt" + }, + "save_hedgehogs": { + "name": "Igel retten" + }, + "one_time_mowing_edge_cut": { + "name": "Kantenschnitt" + } + }, + "button": { + "refresh": { + "name": "Aktualisieren" + }, + "reset_blade_counter": { + "name": "Messer-Laufzeit zurücksetzen" + }, + "reset_battery_cycle_counter": { + "name": "Akkuzyklen zurücksetzen" + }, + "start_edge_cut": { + "name": "Kantenschnitt starten" + }, + "start_one_time_mowing": { + "name": "Einmaliges Mähen starten" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Regenverzögerung" + }, + "time_extension": { + "name": "Zeitverlängerung" + }, + "lawn_area": { + "name": "Rasenfläche" + }, + "lawn_perimeter": { + "name": "Rasenumfang" + }, + "one_time_mowing_runtime": { + "name": "Laufzeit für einmaliges Mähen" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Zonen für einmaliges Mähen" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "RTK-Karte" + } + }, + "calendar": { + "schedule": { + "name": "Mähplan" + } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK-Position" + } + } + } +} From fd5fe0c9836c55cf4a0e816d096fd01e6af1d698 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:00:44 +0200 Subject: [PATCH 06/21] Localize one-time mowing zone select (name + dynamic options) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the select to a shorter 'Mowing zone' / 'Zone de tonte' / 'Mähzone' - Localize the dynamically built zone option labels (all zones / Zone N / Zones N, M) from the HA UI language instead of hard-coded Polish; Polish wording preserved, unknown languages fall back to English --- custom_components/worx_vision_cloud/select.py | 68 +++++++++++++++---- .../worx_vision_cloud/translations/de.json | 2 +- .../worx_vision_cloud/translations/en.json | 2 +- .../worx_vision_cloud/translations/fr.json | 2 +- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/custom_components/worx_vision_cloud/select.py b/custom_components/worx_vision_cloud/select.py index a88d202..213a1c5 100644 --- a/custom_components/worx_vision_cloud/select.py +++ b/custom_components/worx_vision_cloud/select.py @@ -14,9 +14,36 @@ from .entity import WorxVisionEntity from .helpers import get_dict_value, rtk_map_attributes -ALL_ZONES_OPTION = "Wszystkie strefy" +DEFAULT_LANGUAGE = "en" MAX_COMBINATION_ZONES = 5 +# The select options are built from dynamic RTK zone combinations, so they cannot be +# declared in translations/*.json. They are localized here from the HA UI language; +# unknown languages fall back to English. Polish wording is preserved. +ALL_ZONES_LABELS = { + "en": "All zones", + "fr": "Toutes les zones", + "de": "Alle Zonen", + "pl": "Wszystkie strefy", +} +ZONE_SINGULAR_LABELS = { + "en": "Zone", + "fr": "Zone", + "de": "Zone", + "pl": "Strefa", +} +ZONE_PLURAL_LABELS = { + "en": "Zones", + "fr": "Zones", + "de": "Zonen", + "pl": "Strefy", +} + + +def _all_zones_label(language: str) -> str: + """Return the localized 'all zones' option label.""" + return ALL_ZONES_LABELS.get(language, ALL_ZONES_LABELS[DEFAULT_LANGUAGE]) + async def async_setup_entry( hass: HomeAssistant, @@ -48,26 +75,28 @@ def _zone_ids(device: Any) -> list[int]: return sorted(zone_ids) -def _option_label(zone_ids: list[int]) -> str: +def _option_label(zone_ids: list[int], language: str = DEFAULT_LANGUAGE) -> str: """Return a user-facing label for one zone selection.""" if not zone_ids: - return ALL_ZONES_OPTION + return _all_zones_label(language) if len(zone_ids) == 1: - return f"Strefa {zone_ids[0]}" - return "Strefy " + ", ".join(str(zone_id) for zone_id in zone_ids) + singular = ZONE_SINGULAR_LABELS.get(language, ZONE_SINGULAR_LABELS[DEFAULT_LANGUAGE]) + return f"{singular} {zone_ids[0]}" + plural = ZONE_PLURAL_LABELS.get(language, ZONE_PLURAL_LABELS[DEFAULT_LANGUAGE]) + return plural + " " + ", ".join(str(zone_id) for zone_id in zone_ids) -def _option_map(zone_ids: list[int]) -> dict[str, list[int]]: +def _option_map(zone_ids: list[int], language: str = DEFAULT_LANGUAGE) -> dict[str, list[int]]: """Return select option label to zone ID list mapping.""" - result: dict[str, list[int]] = {ALL_ZONES_OPTION: []} + result: dict[str, list[int]] = {_all_zones_label(language): []} if len(zone_ids) <= MAX_COMBINATION_ZONES: for count in range(1, len(zone_ids) + 1): for combo in combinations(zone_ids, count): selected = list(combo) - result[_option_label(selected)] = selected + result[_option_label(selected, language)] = selected else: for zone_id in zone_ids: - result[_option_label([zone_id])] = [zone_id] + result[_option_label([zone_id], language)] = [zone_id] return result @@ -81,12 +110,20 @@ def __init__(self, coordinator, entry, serial_number: str) -> None: """Initialize one-time mowing zones select.""" super().__init__(coordinator, entry, serial_number, "one_time_mowing_zones") + @property + def _language(self) -> str: + """Return the active Home Assistant UI language.""" + hass = getattr(self, "hass", None) + config = getattr(hass, "config", None) + return getattr(config, "language", None) or DEFAULT_LANGUAGE + @property def options(self) -> list[str]: """Return available zone choices.""" - options = _option_map(_zone_ids(self.device)) + language = self._language + options = _option_map(_zone_ids(self.device), language) current_label = _option_label( - self.coordinator.one_time_mowing_zones(self._serial_number) + self.coordinator.one_time_mowing_zones(self._serial_number), language ) if current_label not in options: options[current_label] = self.coordinator.one_time_mowing_zones( @@ -97,7 +134,9 @@ def options(self) -> list[str]: @property def current_option(self) -> str | None: """Return selected zone choice.""" - return _option_label(self.coordinator.one_time_mowing_zones(self._serial_number)) + return _option_label( + self.coordinator.one_time_mowing_zones(self._serial_number), self._language + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -111,9 +150,10 @@ def extra_state_attributes(self) -> dict[str, Any]: async def async_select_option(self, option: str) -> None: """Select one zone choice.""" - options = _option_map(_zone_ids(self.device)) + language = self._language + options = _option_map(_zone_ids(self.device), language) current_zones = self.coordinator.one_time_mowing_zones(self._serial_number) - current_label = _option_label(current_zones) + current_label = _option_label(current_zones, language) if current_label not in options: options[current_label] = current_zones if option not in options: diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json index 5d089e9..c9fc01f 100644 --- a/custom_components/worx_vision_cloud/translations/de.json +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -291,7 +291,7 @@ }, "select": { "one_time_mowing_zones": { - "name": "Zonen für einmaliges Mähen" + "name": "Mähzone" } }, "update": { diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index 97aba7e..56c46bf 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -291,7 +291,7 @@ }, "select": { "one_time_mowing_zones": { - "name": "One-time mowing zones" + "name": "Mowing zone" } }, "update": { diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index ff0851e..da65200 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -291,7 +291,7 @@ }, "select": { "one_time_mowing_zones": { - "name": "Zones de la tonte unique" + "name": "Zone de tonte" } }, "update": { From 5c28da6f4e0b9ebb67cd8a3cbb4748aa8857c89d Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:26:35 +0200 Subject: [PATCH 07/21] Add next-schedule sensor, split area mowed into total + daily - New 'Next schedule' timestamp sensor exposing the next mowing start (drop-in replacement for the MTrab landroid_cloud entity) - The mower's area_mowed is a lifetime total: expose it as a new 'Total area mowed' sensor (state_class total_increasing) - 'Area mowed today' is now a real daily value (delta from a midnight baseline, persisted across restarts via RestoreSensor) - Fix 'Daily progress' / 'Remaining to mow' to use today's area instead of the lifetime total (previously always clamped to 100% / 0%) - Add next_schedule + area_mowed_total names in en/pl/fr/de --- .../worx_vision_cloud/helpers.py | 40 +++- custom_components/worx_vision_cloud/sensor.py | 202 ++++++++++++++---- .../worx_vision_cloud/translations/de.json | 6 + .../worx_vision_cloud/translations/en.json | 6 + .../worx_vision_cloud/translations/fr.json | 6 + .../worx_vision_cloud/translations/pl.json | 6 + 6 files changed, 218 insertions(+), 48 deletions(-) diff --git a/custom_components/worx_vision_cloud/helpers.py b/custom_components/worx_vision_cloud/helpers.py index cd9190d..0fcbd8b 100644 --- a/custom_components/worx_vision_cloud/helpers.py +++ b/custom_components/worx_vision_cloud/helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterable -from datetime import date, datetime +from datetime import date, datetime, time, timedelta from enum import Enum import json from math import cos, hypot, radians @@ -368,6 +368,44 @@ def schedule_day_index(day: Any) -> int | None: return SCHEDULE_DAY_INDEX.get(str(day).lower()) +def parse_schedule_time(value: Any) -> time | None: + """Parse an HH:MM schedule time from pyworxcloud data.""" + if not isinstance(value, str) or ":" not in value: + return None + hour, minute, *_ = value.split(":") + try: + return time(hour=int(hour), minute=int(minute)) + except ValueError: + return None + + +def next_schedule_start(device: Any, now: datetime) -> datetime | None: + """Return the next scheduled mowing start at or after ``now``. + + Looks ahead up to seven days across the weekly schedule slots. Returns a + timezone-aware datetime (matching ``now``'s tzinfo) or None when no schedule + is configured. + """ + slots = schedule_slots(device) + if not slots: + return None + + candidates: list[datetime] = [] + for offset in range(0, 8): + day = (now + timedelta(days=offset)).date() + for slot in slots: + if schedule_day_index(get_dict_value(slot, "day")) != day.weekday(): + continue + start_time = parse_schedule_time(get_dict_value(slot, "start")) + if start_time is None: + continue + start = datetime.combine(day, start_time, tzinfo=now.tzinfo) + if start >= now: + candidates.append(start) + + return min(candidates) if candidates else None + + def schedule_day_label(day: Any) -> str: """Return a short human label for a schedule day.""" if day is None: diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index f4cf65c..ad832fc 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -7,6 +7,7 @@ from typing import Any, Callable from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -25,6 +26,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .const import ( ATTR_RAW_PATH, @@ -37,6 +39,7 @@ from .helpers import ( MAX_STRING_STATE_LENGTH, get_dict_value, + next_schedule_start, raw_entity_path_map, raw_entity_values, raw_path_enabled_default, @@ -232,7 +235,8 @@ def _first_map_zone(device): return {} -def _area_mowed_today(device): +def _area_mowed_total(device): + """Return the lifetime total mowed area reported by the mower (m²).""" value = _product_item(device, "area_mowed") try: return round(float(value), 2) @@ -287,7 +291,7 @@ def _since_reset(device, total_key: str, reset_key: str) -> int | None: def _mowing_efficiency(device) -> float | None: - area = _area_mowed_today(device) + area = _area_mowed_total(device) work_minutes = _as_float(_product_item(device, "mower_work_time")) if area is None or work_minutes in (None, 0): return None @@ -498,21 +502,6 @@ def _lawn_area(device): return round(area, 2) if area > 0 else None -def _daily_progress(device): - area_mowed = _area_mowed_today(device) - lawn_area = _lawn_area(device) - if area_mowed is None or lawn_area in (None, 0): - return None - return round(max(0, min(100, area_mowed / lawn_area * 100)), 1) - - -def _remaining_progress(device): - progress = _daily_progress(device) - if progress is None: - return None - return round(max(0, 100 - progress), 1) - - def _first_address_text(address: dict[str, Any], *keys: str) -> str | None: """Return the first non-empty text value from an address dict.""" for key in keys: @@ -700,38 +689,14 @@ def _rtk_address_attributes( }, ), WorxSensorDescription( - key="daily_progress", - translation_key="daily_progress", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - icon="mdi:progress-check", - value_fn=_daily_progress, - attrs_fn=lambda d: { - "area_mowed": _area_mowed_today(d), - "lawn_area": _lawn_area(d), - }, - ), - WorxSensorDescription( - key="remaining_progress", - translation_key="remaining_progress", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - icon="mdi:progress-clock", - value_fn=_remaining_progress, - attrs_fn=lambda d: { - "daily_progress": _daily_progress(d), - "area_mowed": _area_mowed_today(d), - "lawn_area": _lawn_area(d), - }, - ), - WorxSensorDescription( - key="area_mowed_today", - translation_key="area_mowed_today", + key="area_mowed_total", + translation_key="area_mowed_total", native_unit_of_measurement=UnitOfArea.SQUARE_METERS, device_class=SensorDeviceClass.AREA, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:grass", - value_fn=_area_mowed_today, + value_fn=_area_mowed_total, + attrs_fn=lambda d: {"lawn_area": _lawn_area(d)}, ), WorxSensorDescription( key="lawn_area", @@ -751,7 +716,7 @@ def _rtk_address_attributes( icon="mdi:speedometer", value_fn=_mowing_efficiency, attrs_fn=lambda d: { - "area_mowed": _area_mowed_today(d), + "area_mowed": _area_mowed_total(d), "mower_work_time": _product_item(d, "mower_work_time"), }, ), @@ -977,6 +942,10 @@ async def async_setup_entry( for description in STANDARD_SENSORS ) entities.append(WorxVisionAddressSensor(coordinator, entry, serial_number)) + entities.append(WorxNextScheduleSensor(coordinator, entry, serial_number)) + entities.append(WorxAreaMowedTodaySensor(coordinator, entry, serial_number)) + entities.append(WorxDailyProgressSensor(coordinator, entry, serial_number)) + entities.append(WorxRemainingProgressSensor(coordinator, entry, serial_number)) def add_raw_entities() -> None: raw_entities: list[SensorEntity] = [] @@ -1040,6 +1009,145 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return {key: value for key, value in (attrs or {}).items() if value is not None} +class WorxNextScheduleSensor(WorxVisionEntity, SensorEntity): + """Timestamp of the next scheduled mowing start.""" + + _attr_translation_key = "next_schedule" + _attr_icon = "mdi:calendar-arrow-right" + _attr_device_class = SensorDeviceClass.TIMESTAMP + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize the next schedule sensor.""" + super().__init__(coordinator, entry, serial_number, "next_schedule") + + @property + def native_value(self) -> datetime | None: + """Return the next scheduled mowing start.""" + return next_schedule_start(self.device, dt_util.now()) + + +class _WorxDailyMowedBase(WorxVisionEntity, RestoreSensor): + """Base for sensors derived from the area mowed since local midnight. + + The mower only reports a lifetime total area, so the daily value is the + difference from a baseline captured at the start of each day. The baseline + is persisted as state attributes so it survives Home Assistant restarts. + """ + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator, entry, serial_number: str, key: str) -> None: + """Initialize the daily base sensor.""" + super().__init__(coordinator, entry, serial_number, key) + self._baseline_total: float | None = None + self._baseline_date: str | None = None + + async def async_added_to_hass(self) -> None: + """Restore the saved daily baseline.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is None: + return + try: + self._baseline_total = float(last_state.attributes["baseline_total"]) + except (KeyError, TypeError, ValueError): + self._baseline_total = None + self._baseline_date = last_state.attributes.get("baseline_date") + + def _today_area(self) -> float | None: + """Return the area mowed since local midnight (m²).""" + total = _area_mowed_total(self.device) + if total is None: + return None + today = dt_util.now().date().isoformat() + if ( + self._baseline_total is None + or self._baseline_date != today + or total < self._baseline_total + ): + self._baseline_total = total + self._baseline_date = today + return round(max(0.0, total - self._baseline_total), 2) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Persist the daily baseline so it survives restarts.""" + return { + "baseline_total": self._baseline_total, + "baseline_date": self._baseline_date, + } + + +class WorxAreaMowedTodaySensor(_WorxDailyMowedBase): + """Area mowed since local midnight.""" + + _attr_translation_key = "area_mowed_today" + _attr_device_class = SensorDeviceClass.AREA + _attr_native_unit_of_measurement = UnitOfArea.SQUARE_METERS + _attr_icon = "mdi:grass" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize area mowed today.""" + super().__init__(coordinator, entry, serial_number, "area_mowed_today") + + @property + def native_value(self) -> float | None: + """Return today's mowed area.""" + return self._today_area() + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the baseline plus reference figures.""" + return { + **super().extra_state_attributes, + "area_mowed_total": _area_mowed_total(self.device), + "lawn_area": _lawn_area(self.device), + } + + +class WorxDailyProgressSensor(_WorxDailyMowedBase): + """Percentage of the lawn mowed today.""" + + _attr_translation_key = "daily_progress" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_icon = "mdi:progress-check" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize daily progress.""" + super().__init__(coordinator, entry, serial_number, "daily_progress") + + @property + def native_value(self) -> float | None: + """Return today's progress in percent.""" + today = self._today_area() + lawn_area = _lawn_area(self.device) + if today is None or lawn_area in (None, 0): + return None + return round(max(0, min(100, today / lawn_area * 100)), 1) + + +class WorxRemainingProgressSensor(_WorxDailyMowedBase): + """Percentage of the lawn still to mow today.""" + + _attr_translation_key = "remaining_progress" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_icon = "mdi:progress-clock" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize remaining progress.""" + super().__init__(coordinator, entry, serial_number, "remaining_progress") + + @property + def native_value(self) -> float | None: + """Return the remaining lawn percentage for today.""" + today = self._today_area() + lawn_area = _lawn_area(self.device) + if today is None or lawn_area in (None, 0): + return None + progress = max(0, min(100, today / lawn_area * 100)) + return round(max(0, 100 - progress), 1) + + class WorxVisionAddressSensor(WorxVisionEntity, SensorEntity): """Reverse-geocoded RTK address sensor.""" diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json index c9fc01f..2c92ad0 100644 --- a/custom_components/worx_vision_cloud/translations/de.json +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -116,6 +116,12 @@ "area_mowed_today": { "name": "Heute gemähte Fläche" }, + "next_schedule": { + "name": "Nächster Zeitplan" + }, + "area_mowed_total": { + "name": "Insgesamt gemähte Fläche" + }, "lawn_area": { "name": "Rasenfläche" }, diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index 56c46bf..90485e9 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -116,6 +116,12 @@ "area_mowed_today": { "name": "Area mowed today" }, + "next_schedule": { + "name": "Next schedule" + }, + "area_mowed_total": { + "name": "Total area mowed" + }, "lawn_area": { "name": "Lawn area" }, diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index da65200..c3d26c4 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -116,6 +116,12 @@ "area_mowed_today": { "name": "Surface tondue aujourd'hui" }, + "next_schedule": { + "name": "Prochaine tonte" + }, + "area_mowed_total": { + "name": "Surface totale tondue" + }, "lawn_area": { "name": "Surface de la pelouse" }, diff --git a/custom_components/worx_vision_cloud/translations/pl.json b/custom_components/worx_vision_cloud/translations/pl.json index 2ab88c0..74e5508 100644 --- a/custom_components/worx_vision_cloud/translations/pl.json +++ b/custom_components/worx_vision_cloud/translations/pl.json @@ -116,6 +116,12 @@ "area_mowed_today": { "name": "Skoszony obszar dzisiaj" }, + "next_schedule": { + "name": "Następny harmonogram" + }, + "area_mowed_total": { + "name": "Łącznie skoszona powierzchnia" + }, "lawn_area": { "name": "Powierzchnia trawnika" }, From 5b207baf7b4ee2403561a182b9e1e4b7a35be59b Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:19:58 +0200 Subject: [PATCH 08/21] Localize schedule day names and calendar event text by UI language - Weekday abbreviations (schedule sensor, calendar, attributes) now follow the HA language: en/de/fr/pl (e.g. German Mo/Di/Mi...), English fallback - Localize the schedule sensor free-text fallbacks (+ edge / no active slots) - Localize the hard-coded Polish calendar event title and description (Mowing / Day / Duration / Edge cutting / Source) per language - Convert the schedule sensor to a class so it can read the UI language --- .../worx_vision_cloud/calendar.py | 42 +++++++++-- .../worx_vision_cloud/helpers.py | 74 +++++++++++++------ custom_components/worx_vision_cloud/sensor.py | 36 +++++++-- 3 files changed, 115 insertions(+), 37 deletions(-) diff --git a/custom_components/worx_vision_cloud/calendar.py b/custom_components/worx_vision_cloud/calendar.py index 70fd988..9fffb26 100644 --- a/custom_components/worx_vision_cloud/calendar.py +++ b/custom_components/worx_vision_cloud/calendar.py @@ -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, @@ -45,6 +61,12 @@ 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.""" @@ -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) @@ -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: @@ -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 @@ -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), ) diff --git a/custom_components/worx_vision_cloud/helpers.py b/custom_components/worx_vision_cloud/helpers.py index 0fcbd8b..5ce6fe0 100644 --- a/custom_components/worx_vision_cloud/helpers.py +++ b/custom_components/worx_vision_cloud/helpers.py @@ -48,16 +48,42 @@ "schedules.slots.count", } +SCHEDULE_DEFAULT_LANGUAGE = "en" + +# Schedule text is free-form sensor state that Home Assistant cannot translate +# through translations/*.json, so it is localized here from the UI language. SCHEDULE_DAY_LABELS = { - "monday": "Mon", - "tuesday": "Tue", - "wednesday": "Wed", - "thursday": "Thu", - "friday": "Fri", - "saturday": "Sat", - "sunday": "Sun", + "en": { + "monday": "Mon", "tuesday": "Tue", "wednesday": "Wed", "thursday": "Thu", + "friday": "Fri", "saturday": "Sat", "sunday": "Sun", + }, + "de": { + "monday": "Mo", "tuesday": "Di", "wednesday": "Mi", "thursday": "Do", + "friday": "Fr", "saturday": "Sa", "sunday": "So", + }, + "fr": { + "monday": "lun", "tuesday": "mar", "wednesday": "mer", "thursday": "jeu", + "friday": "ven", "saturday": "sam", "sunday": "dim", + }, + "pl": { + "monday": "pon", "tuesday": "wt", "wednesday": "śr", "thursday": "czw", + "friday": "pt", "saturday": "sob", "sunday": "niedz", + }, +} + +SCHEDULE_TEXT_LABELS = { + "en": {"none": "no active slots", "count": "{count} active slots", "edge": "+ edge"}, + "de": {"none": "keine aktiven Zeitfenster", "count": "{count} aktive Zeitfenster", "edge": "+ Kante"}, + "fr": {"none": "aucun créneau actif", "count": "{count} créneaux actifs", "edge": "+ bordure"}, + "pl": {"none": "brak aktywnych slotów", "count": "{count} aktywnych slotów", "edge": "+ krawędź"}, } + +def schedule_language(language: Any) -> str: + """Return a supported schedule language code (falls back to English).""" + code = str(language or "").lower().split("-")[0] + return code if code in SCHEDULE_DAY_LABELS else SCHEDULE_DEFAULT_LANGUAGE + SCHEDULE_DAY_INDEX = { "monday": 0, "tuesday": 1, @@ -406,17 +432,18 @@ def next_schedule_start(device: Any, now: datetime) -> datetime | None: return min(candidates) if candidates else None -def schedule_day_label(day: Any) -> str: - """Return a short human label for a schedule day.""" +def schedule_day_label(day: Any, language: str = SCHEDULE_DEFAULT_LANGUAGE) -> str: + """Return a short, localized human label for a schedule day.""" if day is None: return "" - day_text = str(day).lower() - return SCHEDULE_DAY_LABELS.get(day_text, str(day)) + labels = SCHEDULE_DAY_LABELS[schedule_language(language)] + return labels.get(str(day).lower(), str(day)) -def schedule_slot_summary(slot: Any) -> str: - """Return one compact schedule slot line.""" - day = schedule_day_label(get_dict_value(slot, "day")) +def schedule_slot_summary(slot: Any, language: str = SCHEDULE_DEFAULT_LANGUAGE) -> str: + """Return one compact, localized schedule slot line.""" + lang = schedule_language(language) + day = schedule_day_label(get_dict_value(slot, "day"), lang) start = get_dict_value(slot, "start") end = get_dict_value(slot, "end") duration = get_dict_value(slot, "duration_extended") @@ -431,23 +458,26 @@ def schedule_slot_summary(slot: Any) -> str: text = day or "slot" if get_dict_value(slot, "boundary"): - text = f"{text} + edge" + text = f"{text} {SCHEDULE_TEXT_LABELS[lang]['edge']}" return text -def schedule_summary(device: Any) -> str | None: - """Return a compact schedule summary for Home Assistant state.""" +def schedule_summary(device: Any, language: str = SCHEDULE_DEFAULT_LANGUAGE) -> str | None: + """Return a compact, localized schedule summary for Home Assistant state.""" + lang = schedule_language(language) slots = schedule_slots(device) if not slots: - return "no active slots" + return SCHEDULE_TEXT_LABELS[lang]["none"] - summary = ", ".join(schedule_slot_summary(slot) for slot in slots) + summary = ", ".join(schedule_slot_summary(slot, lang) for slot in slots) if len(summary) <= MAX_STRING_STATE_LENGTH: return summary - return f"{len(slots)} active slots" + return SCHEDULE_TEXT_LABELS[lang]["count"].format(count=len(slots)) -def schedule_attributes(device: Any) -> dict[str, Any]: +def schedule_attributes( + device: Any, language: str = SCHEDULE_DEFAULT_LANGUAGE +) -> dict[str, Any]: """Return structured schedule data for cards and templates.""" schedules = getattr(device, "schedules", {}) or {} slots = schedule_slots(device) @@ -458,7 +488,7 @@ def schedule_attributes(device: Any) -> dict[str, Any]: "slots": [ { "day": get_dict_value(slot, "day"), - "day_label": schedule_day_label(get_dict_value(slot, "day")), + "day_label": schedule_day_label(get_dict_value(slot, "day"), language), "start": get_dict_value(slot, "start"), "end": get_dict_value(slot, "end"), "duration": get_dict_value(slot, "duration"), diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index ad832fc..40d8b4c 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -641,13 +641,6 @@ def _rtk_address_attributes( "starting_point": _zone(d, "starting_point"), }, ), - WorxSensorDescription( - key="schedule", - translation_key="schedule", - icon="mdi:calendar-clock", - value_fn=schedule_summary, - attrs_fn=schedule_attributes, - ), WorxSensorDescription( key="mowing_readiness", translation_key="mowing_readiness", @@ -942,6 +935,7 @@ async def async_setup_entry( for description in STANDARD_SENSORS ) entities.append(WorxVisionAddressSensor(coordinator, entry, serial_number)) + entities.append(WorxScheduleSensor(coordinator, entry, serial_number)) entities.append(WorxNextScheduleSensor(coordinator, entry, serial_number)) entities.append(WorxAreaMowedTodaySensor(coordinator, entry, serial_number)) entities.append(WorxDailyProgressSensor(coordinator, entry, serial_number)) @@ -1026,6 +1020,34 @@ def native_value(self) -> datetime | None: return next_schedule_start(self.device, dt_util.now()) +class WorxScheduleSensor(WorxVisionEntity, SensorEntity): + """Compact weekly schedule summary, localized to the UI language.""" + + _attr_translation_key = "schedule" + _attr_icon = "mdi:calendar-clock" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize the schedule sensor.""" + super().__init__(coordinator, entry, serial_number, "schedule") + + @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 native_value(self) -> str | None: + """Return the localized schedule summary.""" + return schedule_summary(self.device, self._language) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return structured schedule data.""" + attrs = schedule_attributes(self.device, self._language) + return {key: value for key, value in attrs.items() if value is not None} + + class _WorxDailyMowedBase(WorxVisionEntity, RestoreSensor): """Base for sensors derived from the area mowed since local midnight. From 9a7d56ae4ab8498e72ef9ae4f4de781157c726fc Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:58:00 +0200 Subject: [PATCH 09/21] Next schedule: prefer pyworxcloud's computed value Use schedules[next_schedule_start] from pyworxcloud (the authoritative, upstream-maintained computation, 14-day lookahead) when present, and fall back to deriving it from the weekly slots ourselves. No regression: the fallback covers the case where the library value is missing or unparseable. --- .../worx_vision_cloud/helpers.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/custom_components/worx_vision_cloud/helpers.py b/custom_components/worx_vision_cloud/helpers.py index 5ce6fe0..b333638 100644 --- a/custom_components/worx_vision_cloud/helpers.py +++ b/custom_components/worx_vision_cloud/helpers.py @@ -405,13 +405,36 @@ def parse_schedule_time(value: Any) -> time | None: return None +def _library_next_schedule_start(device: Any, now: datetime) -> datetime | None: + """Return the next start computed by pyworxcloud, if available. + + pyworxcloud exposes ``schedules["next_schedule_start"]`` as a wall-clock + string ("%Y-%m-%d %H:%M:%S"); the digits are the local schedule time, so we + attach ``now``'s timezone to make it timezone-aware. + """ + schedules = getattr(device, "schedules", {}) or {} + raw = get_dict_value(schedules, "next_schedule_start") + if not isinstance(raw, str) or not raw.strip(): + return None + try: + naive = datetime.strptime(raw.strip(), "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + return naive.replace(tzinfo=now.tzinfo) + + def next_schedule_start(device: Any, now: datetime) -> datetime | None: """Return the next scheduled mowing start at or after ``now``. - Looks ahead up to seven days across the weekly schedule slots. Returns a - timezone-aware datetime (matching ``now``'s tzinfo) or None when no schedule - is configured. + Prefers the value already computed by pyworxcloud + (``schedules["next_schedule_start"]``) and falls back to deriving it from the + weekly slots ourselves. Returns a timezone-aware datetime (matching ``now``'s + tzinfo) or None when no schedule is configured. """ + from_library = _library_next_schedule_start(device, now) + if from_library is not None: + return from_library + slots = schedule_slots(device) if not slots: return None From ec777b769414135b051f27a28dfa806d03d838c9 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:13:46 +0200 Subject: [PATCH 10/21] Use non-deprecated device_tracker imports Import TrackerEntity and SourceType from homeassistant.components.device_tracker instead of the deprecated .config_entry / .const aliases (removed in HA 2027.6). --- custom_components/worx_vision_cloud/device_tracker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/worx_vision_cloud/device_tracker.py b/custom_components/worx_vision_cloud/device_tracker.py index 86486b3..de4e022 100644 --- a/custom_components/worx_vision_cloud/device_tracker.py +++ b/custom_components/worx_vision_cloud/device_tracker.py @@ -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 From fc0869f3033c00ea30e88be53a489a0267822c7a Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:21:02 +0200 Subject: [PATCH 11/21] Docs: document new sensors, languages and mowed-area meaning - Mention next schedule sensor, today/total mowed area, FR/DE translations - Add a Mowed area section clarifying that figures are covered area (overlapping passes), so they can exceed the lawn size --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1931bd4..fd518c0 100644 --- a/README.md +++ b/README.md @@ -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. +- Polish, English, French and German translations, including localized entity states, schedule and calendar. - Optional raw payload entities for debugging, disabled by default. ## Installation With HACS @@ -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 @@ -126,6 +126,10 @@ 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. + ## 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. From 4004e4b34b2748d83b2f07e52dfe256722715b36 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:33:01 +0200 Subject: [PATCH 12/21] Give the primary mower entity no name (HA convention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set _attr_name = None on the lawn_mower entity instead of translation_key 'mower'. It is the device's primary entity, so with has_entity_name=True its friendly_name now becomes exactly the device name. This also fixes third-party cards like landroid-card, which strip the device name from other entities' friendly_name by matching the primary entity's friendly_name as the device name prefix — previously that prefix included ' Mower'/' Tondeuse'/etc. and never matched, so every entity in info_card / statistics_card / battery_card kept showing the full 'Vision Cloud '. Removed the now-unused lawn_mower.mower.name key from en/pl/fr/de. --- custom_components/worx_vision_cloud/lawn_mower.py | 7 ++++++- custom_components/worx_vision_cloud/translations/de.json | 5 ----- custom_components/worx_vision_cloud/translations/en.json | 5 ----- custom_components/worx_vision_cloud/translations/fr.json | 5 ----- custom_components/worx_vision_cloud/translations/pl.json | 5 ----- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/custom_components/worx_vision_cloud/lawn_mower.py b/custom_components/worx_vision_cloud/lawn_mower.py index 323871d..ef98cf9 100644 --- a/custom_components/worx_vision_cloud/lawn_mower.py +++ b/custom_components/worx_vision_cloud/lawn_mower.py @@ -70,6 +70,12 @@ async def async_setup_entry( class WorxVisionLawnMower(WorxVisionEntity, LawnMowerEntity): """Worx Landroid mower entity.""" + # This is the device's primary/main entity, so it has no name of its own + # (HA convention): with has_entity_name=True its friendly_name becomes + # exactly the device name, which is what lets companion cards like + # landroid-card correctly strip the device name from other entities. + _attr_name = None + _attr_supported_features = ( LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.PAUSE @@ -79,7 +85,6 @@ class WorxVisionLawnMower(WorxVisionEntity, LawnMowerEntity): def __init__(self, coordinator, entry, serial_number: str) -> None: """Initialize mower.""" super().__init__(coordinator, entry, serial_number, "mower") - self._attr_translation_key = "mower" @property def available(self) -> bool: diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json index 2c92ad0..5ce6383 100644 --- a/custom_components/worx_vision_cloud/translations/de.json +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -24,11 +24,6 @@ } }, "entity": { - "lawn_mower": { - "mower": { - "name": "Mähroboter" - } - }, "sensor": { "battery_percent": { "name": "Akku" diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index 90485e9..d07b936 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -24,11 +24,6 @@ } }, "entity": { - "lawn_mower": { - "mower": { - "name": "Mower" - } - }, "sensor": { "battery_percent": { "name": "Battery" diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index c3d26c4..0d66cd4 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -24,11 +24,6 @@ } }, "entity": { - "lawn_mower": { - "mower": { - "name": "Tondeuse" - } - }, "sensor": { "battery_percent": { "name": "Batterie" diff --git a/custom_components/worx_vision_cloud/translations/pl.json b/custom_components/worx_vision_cloud/translations/pl.json index 74e5508..1cb3588 100644 --- a/custom_components/worx_vision_cloud/translations/pl.json +++ b/custom_components/worx_vision_cloud/translations/pl.json @@ -24,11 +24,6 @@ } }, "entity": { - "lawn_mower": { - "mower": { - "name": "Kosiarka" - } - }, "sensor": { "battery_percent": { "name": "Bateria" From 912a8356915e85b498bb1b331a472677bf41170c Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:34:51 +0200 Subject: [PATCH 13/21] Docs: explain the primary entity naming change Add an Entity naming section clarifying why the lawn_mower entity has no name of its own: readability, and compatibility with cards like landroid-card that strip the device name from other entities' labels. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index fd518c0..8907df3 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ Before opening an issue, remove private data from logs and screenshots. See [SEC 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. From 16a6b52ee0b710bac066027776ffb0a4371c1662 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:46:00 +0200 Subject: [PATCH 14/21] Add Dutch, Spanish, Italian, Swedish, Norwegian and Danish translations Same structure and coverage as en/pl/fr/de (222 keys each, including sensor states, calendar/schedule names, zone select). Natural wording, matching the language coverage of the community landroid_cloud integration. --- .../worx_vision_cloud/translations/da.json | 319 ++++++++++++++++++ .../worx_vision_cloud/translations/es.json | 319 ++++++++++++++++++ .../worx_vision_cloud/translations/it.json | 319 ++++++++++++++++++ .../worx_vision_cloud/translations/nl.json | 319 ++++++++++++++++++ .../worx_vision_cloud/translations/no.json | 319 ++++++++++++++++++ .../worx_vision_cloud/translations/sv.json | 319 ++++++++++++++++++ 6 files changed, 1914 insertions(+) create mode 100644 custom_components/worx_vision_cloud/translations/da.json create mode 100644 custom_components/worx_vision_cloud/translations/es.json create mode 100644 custom_components/worx_vision_cloud/translations/it.json create mode 100644 custom_components/worx_vision_cloud/translations/nl.json create mode 100644 custom_components/worx_vision_cloud/translations/no.json create mode 100644 custom_components/worx_vision_cloud/translations/sv.json diff --git a/custom_components/worx_vision_cloud/translations/da.json b/custom_components/worx_vision_cloud/translations/da.json new file mode 100644 index 0000000..fb29e62 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/da.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Brug samme login som i Worx Landroid-appen.", + "data": { + "email": "E-mail", + "password": "Adgangskode", + "cloud": "Sky", + "verify_ssl": "Bekræft SSL", + "expose_raw_entities": "Vis alle rå datafelter som enheder" + } + } + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse til Worx Cloud.", + "invalid_auth": "Ugyldigt brugernavn eller adgangskode.", + "rate_limited": "Worx Cloud-anmodningsgrænse nået. Prøv igen senere." + }, + "abort": { + "already_configured": "Denne konto er allerede konfigureret." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batteri" + }, + "status": { + "name": "Status", + "state": { + "home": "Ved basestation", + "leaving_home": "Forlader basestation", + "going_home": "På vej til basestation", + "mowing": "Slår græs", + "edge_cutting": "Kantklipning", + "charging": "Oplader", + "paused": "Sat på pause", + "idle": "Inaktiv", + "manual_stop": "Manuelt stoppet", + "rain_delay": "Regnforsinkelse", + "locked": "Låst", + "error": "Fejl", + "no_error": "Ingen fejl", + "offline": "Offline" + } + }, + "error": { + "name": "Fejl", + "state": { + "home": "Ved basestation", + "leaving_home": "Forlader basestation", + "going_home": "På vej til basestation", + "mowing": "Slår græs", + "edge_cutting": "Kantklipning", + "charging": "Oplader", + "paused": "Sat på pause", + "idle": "Inaktiv", + "manual_stop": "Manuelt stoppet", + "rain_delay": "Regnforsinkelse", + "locked": "Låst", + "error": "Fejl", + "no_error": "Ingen fejl", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Nuværende zone" + }, + "schedule": { + "name": "Tidsplan" + }, + "mowing_readiness": { + "name": "Klippeparathed", + "state": { + "ready": "Klar", + "mowing": "Slår græs", + "charging": "Oplader", + "battery_low": "Lavt batteriniveau", + "rain_delay": "Regnforsinkelse", + "error": "Fejl", + "locked": "Låst", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Skyforbindelse", + "state": { + "ok": "Forbundet", + "check": "Kontrollerer", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "API-funktioner" + }, + "push_notifications": { + "name": "Push-notifikationer" + }, + "daily_progress": { + "name": "Dagens fremskridt" + }, + "remaining_progress": { + "name": "Resterende at slå" + }, + "area_mowed_today": { + "name": "Areal slået i dag" + }, + "next_schedule": { + "name": "Næste slåning" + }, + "area_mowed_total": { + "name": "Samlet slået areal" + }, + "lawn_area": { + "name": "Plænens areal" + }, + "mowing_efficiency": { + "name": "Klippeeffektivitet" + }, + "rtk_map": { + "name": "RTK-kort" + }, + "rtk_trail_points": { + "name": "RTK-sporpunkter" + }, + "rtk_address": { + "name": "RTK-adresse" + }, + "rain_delay": { + "name": "Regnforsinkelse" + }, + "rain_remaining": { + "name": "Resterende regnforsinkelse" + }, + "battery_voltage": { + "name": "Batterispænding" + }, + "battery_temperature": { + "name": "Batteritemperatur" + }, + "battery_cycles_total": { + "name": "Battericyklusser (i alt)" + }, + "battery_cycles_since_reset": { + "name": "Battericyklusser siden nulstilling" + }, + "battery_cycles_reset_at": { + "name": "Seneste nulstilling af battericyklusser" + }, + "blade_runtime_total": { + "name": "Knivenes driftstid (i alt)" + }, + "blade_runtime_current": { + "name": "Knivenes driftstid (aktuel)" + }, + "blade_runtime_reset_at": { + "name": "Seneste nulstilling af knivtid" + }, + "mower_runtime_total": { + "name": "Samlet driftstid" + }, + "mower_home_time_total": { + "name": "Samlet tid ved basestation" + }, + "mower_charging_time_total": { + "name": "Samlet opladningstid" + }, + "mower_error_time_total": { + "name": "Samlet tid i fejl" + }, + "maintenance_status": { + "name": "Vedligeholdelsesstatus", + "state": { + "ok": "OK", + "blade_service_due": "Knivservice påkrævet", + "battery_service_due": "Batteriservice påkrævet" + } + }, + "pitch": { + "name": "Pitch" + }, + "roll": { + "name": "Roll" + }, + "yaw": { + "name": "Yaw" + }, + "last_update": { + "name": "Seneste opdatering" + }, + "last_update_age": { + "name": "Tid siden seneste opdatering" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT registreret" + }, + "mqtt_registered": { + "name": "MQTT registreret" + }, + "locked": { + "name": "Låst" + }, + "rain_triggered": { + "name": "Regn registreret" + }, + "robot_lifted": { + "name": "Robot løftet" + }, + "off_limits_enabled": { + "name": "Forbudte zoner aktiveret" + }, + "acs_enabled": { + "name": "ACS aktiveret" + }, + "party_mode_enabled": { + "name": "Festtilstand aktiveret" + }, + "pause_mode_enabled": { + "name": "Pausetilstand aktiveret" + }, + "smart_edge_cut": { + "name": "Smart kantklipning" + }, + "save_hedgehogs": { + "name": "Red pindsvinene" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Automatisk firmwareopdatering" + }, + "lock": { + "name": "Lås" + }, + "native_schedule": { + "name": "Indbygget tidsplan" + }, + "smart_edge_cut": { + "name": "Smart kantklipning" + }, + "save_hedgehogs": { + "name": "Red pindsvinene" + }, + "one_time_mowing_edge_cut": { + "name": "Kantklipning" + } + }, + "button": { + "refresh": { + "name": "Opdater" + }, + "reset_blade_counter": { + "name": "Nulstil knivtid" + }, + "reset_battery_cycle_counter": { + "name": "Nulstil battericyklusser" + }, + "start_edge_cut": { + "name": "Start kantklipning" + }, + "start_one_time_mowing": { + "name": "Start engangsslåning" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Regnforsinkelse" + }, + "time_extension": { + "name": "Tidsforlængelse" + }, + "lawn_area": { + "name": "Plænens areal" + }, + "lawn_perimeter": { + "name": "Plænens omkreds" + }, + "one_time_mowing_runtime": { + "name": "Varighed af engangsslåning" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Klippezone" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "RTK-kort" + } + }, + "calendar": { + "schedule": { + "name": "Slåningsplan" + } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK-position" + } + } + } +} diff --git a/custom_components/worx_vision_cloud/translations/es.json b/custom_components/worx_vision_cloud/translations/es.json new file mode 100644 index 0000000..74099e6 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/es.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Usa las mismas credenciales que en la app Worx Landroid.", + "data": { + "email": "Correo electrónico", + "password": "Contraseña", + "cloud": "Nube", + "verify_ssl": "Verificar SSL", + "expose_raw_entities": "Exponer todos los campos de datos brutos como entidades" + } + } + }, + "error": { + "cannot_connect": "No se pudo conectar con Worx Cloud.", + "invalid_auth": "Usuario o contraseña incorrectos.", + "rate_limited": "Límite de solicitudes de Worx Cloud alcanzado. Inténtalo más tarde." + }, + "abort": { + "already_configured": "Esta cuenta ya está configurada." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batería" + }, + "status": { + "name": "Estado", + "state": { + "home": "En la base", + "leaving_home": "Saliendo de la base", + "going_home": "Regresando a la base", + "mowing": "Cortando", + "edge_cutting": "Corte de bordes", + "charging": "Cargando", + "paused": "En pausa", + "idle": "Inactivo", + "manual_stop": "Detenido manualmente", + "rain_delay": "Retraso por lluvia", + "locked": "Bloqueado", + "error": "Error", + "no_error": "Sin errores", + "offline": "Sin conexión" + } + }, + "error": { + "name": "Error", + "state": { + "home": "En la base", + "leaving_home": "Saliendo de la base", + "going_home": "Regresando a la base", + "mowing": "Cortando", + "edge_cutting": "Corte de bordes", + "charging": "Cargando", + "paused": "En pausa", + "idle": "Inactivo", + "manual_stop": "Detenido manualmente", + "rain_delay": "Retraso por lluvia", + "locked": "Bloqueado", + "error": "Error", + "no_error": "Sin errores", + "offline": "Sin conexión" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Zona actual" + }, + "schedule": { + "name": "Programación" + }, + "mowing_readiness": { + "name": "Disponibilidad para cortar", + "state": { + "ready": "Lista", + "mowing": "Cortando", + "charging": "Cargando", + "battery_low": "Batería baja", + "rain_delay": "Retraso por lluvia", + "error": "Error", + "locked": "Bloqueado", + "offline": "Sin conexión" + } + }, + "cloud_connection": { + "name": "Conexión a la nube", + "state": { + "ok": "Conectado", + "check": "Comprobando", + "offline": "Sin conexión" + } + }, + "api_capabilities": { + "name": "Capacidades de la API" + }, + "push_notifications": { + "name": "Notificaciones push" + }, + "daily_progress": { + "name": "Progreso diario" + }, + "remaining_progress": { + "name": "Restante por cortar" + }, + "area_mowed_today": { + "name": "Superficie cortada hoy" + }, + "next_schedule": { + "name": "Próximo corte" + }, + "area_mowed_total": { + "name": "Superficie total cortada" + }, + "lawn_area": { + "name": "Superficie del césped" + }, + "mowing_efficiency": { + "name": "Eficiencia de corte" + }, + "rtk_map": { + "name": "Mapa RTK" + }, + "rtk_trail_points": { + "name": "Puntos de trayectoria RTK" + }, + "rtk_address": { + "name": "Dirección RTK" + }, + "rain_delay": { + "name": "Retraso por lluvia" + }, + "rain_remaining": { + "name": "Retraso por lluvia restante" + }, + "battery_voltage": { + "name": "Voltaje de la batería" + }, + "battery_temperature": { + "name": "Temperatura de la batería" + }, + "battery_cycles_total": { + "name": "Ciclos de batería (total)" + }, + "battery_cycles_since_reset": { + "name": "Ciclos de batería desde el reinicio" + }, + "battery_cycles_reset_at": { + "name": "Último reinicio de ciclos de batería" + }, + "blade_runtime_total": { + "name": "Tiempo de funcionamiento de cuchillas (total)" + }, + "blade_runtime_current": { + "name": "Tiempo de funcionamiento de cuchillas (actual)" + }, + "blade_runtime_reset_at": { + "name": "Último reinicio del tiempo de cuchillas" + }, + "mower_runtime_total": { + "name": "Tiempo total de funcionamiento" + }, + "mower_home_time_total": { + "name": "Tiempo total en la base" + }, + "mower_charging_time_total": { + "name": "Tiempo total de carga" + }, + "mower_error_time_total": { + "name": "Tiempo total en error" + }, + "maintenance_status": { + "name": "Estado de mantenimiento", + "state": { + "ok": "OK", + "blade_service_due": "Revisión de cuchillas pendiente", + "battery_service_due": "Revisión de batería pendiente" + } + }, + "pitch": { + "name": "Cabeceo" + }, + "roll": { + "name": "Alabeo" + }, + "yaw": { + "name": "Guiñada" + }, + "last_update": { + "name": "Última actualización" + }, + "last_update_age": { + "name": "Antigüedad de la última actualización" + } + }, + "binary_sensor": { + "online": { + "name": "En línea" + }, + "iot_registered": { + "name": "IoT registrado" + }, + "mqtt_registered": { + "name": "MQTT registrado" + }, + "locked": { + "name": "Bloqueado" + }, + "rain_triggered": { + "name": "Lluvia detectada" + }, + "robot_lifted": { + "name": "Robot levantado" + }, + "off_limits_enabled": { + "name": "Zonas prohibidas activadas" + }, + "acs_enabled": { + "name": "ACS activado" + }, + "party_mode_enabled": { + "name": "Modo fiesta activado" + }, + "pause_mode_enabled": { + "name": "Modo pausa activado" + }, + "smart_edge_cut": { + "name": "Corte de bordes inteligente" + }, + "save_hedgehogs": { + "name": "Modo erizos" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Actualización automática de firmware" + }, + "lock": { + "name": "Bloqueo" + }, + "native_schedule": { + "name": "Programación nativa" + }, + "smart_edge_cut": { + "name": "Corte de bordes inteligente" + }, + "save_hedgehogs": { + "name": "Modo erizos" + }, + "one_time_mowing_edge_cut": { + "name": "Corte de bordes" + } + }, + "button": { + "refresh": { + "name": "Actualizar" + }, + "reset_blade_counter": { + "name": "Reiniciar tiempo de cuchillas" + }, + "reset_battery_cycle_counter": { + "name": "Reiniciar ciclos de batería" + }, + "start_edge_cut": { + "name": "Iniciar corte de bordes" + }, + "start_one_time_mowing": { + "name": "Iniciar corte puntual" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Retraso por lluvia" + }, + "time_extension": { + "name": "Prolongación de tiempo" + }, + "lawn_area": { + "name": "Superficie del césped" + }, + "lawn_perimeter": { + "name": "Perímetro del césped" + }, + "one_time_mowing_runtime": { + "name": "Duración del corte puntual" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Zona de corte" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "Mapa RTK" + } + }, + "calendar": { + "schedule": { + "name": "Programación de corte" + } + }, + "device_tracker": { + "rtk_position": { + "name": "Posición RTK" + } + } + } +} diff --git a/custom_components/worx_vision_cloud/translations/it.json b/custom_components/worx_vision_cloud/translations/it.json new file mode 100644 index 0000000..508086c --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/it.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Usa le stesse credenziali dell'app Worx Landroid.", + "data": { + "email": "E-mail", + "password": "Password", + "cloud": "Cloud", + "verify_ssl": "Verifica SSL", + "expose_raw_entities": "Esponi tutti i campi payload grezzi come entità" + } + } + }, + "error": { + "cannot_connect": "Impossibile connettersi a Worx Cloud.", + "invalid_auth": "Nome utente o password non validi.", + "rate_limited": "Limite di richieste Worx Cloud raggiunto. Riprova più tardi." + }, + "abort": { + "already_configured": "Questo account è già configurato." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batteria" + }, + "status": { + "name": "Stato", + "state": { + "home": "In base", + "leaving_home": "Uscita dalla base", + "going_home": "Rientro alla base", + "mowing": "In taglio", + "edge_cutting": "Taglio bordi", + "charging": "In carica", + "paused": "In pausa", + "idle": "Inattivo", + "manual_stop": "Arresto manuale", + "rain_delay": "Ritardo pioggia", + "locked": "Bloccato", + "error": "Errore", + "no_error": "Nessun errore", + "offline": "Offline" + } + }, + "error": { + "name": "Errore", + "state": { + "home": "In base", + "leaving_home": "Uscita dalla base", + "going_home": "Rientro alla base", + "mowing": "In taglio", + "edge_cutting": "Taglio bordi", + "charging": "In carica", + "paused": "In pausa", + "idle": "Inattivo", + "manual_stop": "Arresto manuale", + "rain_delay": "Ritardo pioggia", + "locked": "Bloccato", + "error": "Errore", + "no_error": "Nessun errore", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Zona attuale" + }, + "schedule": { + "name": "Programma" + }, + "mowing_readiness": { + "name": "Prontezza al taglio", + "state": { + "ready": "Pronto", + "mowing": "In taglio", + "charging": "In carica", + "battery_low": "Batteria scarica", + "rain_delay": "Ritardo pioggia", + "error": "Errore", + "locked": "Bloccato", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Connessione al cloud", + "state": { + "ok": "Connesso", + "check": "Verifica in corso", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "Funzionalità API" + }, + "push_notifications": { + "name": "Notifiche push" + }, + "daily_progress": { + "name": "Avanzamento giornaliero" + }, + "remaining_progress": { + "name": "Rimanente da tagliare" + }, + "area_mowed_today": { + "name": "Superficie tagliata oggi" + }, + "next_schedule": { + "name": "Prossimo taglio" + }, + "area_mowed_total": { + "name": "Superficie totale tagliata" + }, + "lawn_area": { + "name": "Superficie del prato" + }, + "mowing_efficiency": { + "name": "Efficienza di taglio" + }, + "rtk_map": { + "name": "Mappa RTK" + }, + "rtk_trail_points": { + "name": "Punti traccia RTK" + }, + "rtk_address": { + "name": "Indirizzo RTK" + }, + "rain_delay": { + "name": "Ritardo pioggia" + }, + "rain_remaining": { + "name": "Ritardo pioggia residuo" + }, + "battery_voltage": { + "name": "Tensione della batteria" + }, + "battery_temperature": { + "name": "Temperatura della batteria" + }, + "battery_cycles_total": { + "name": "Cicli batteria (totale)" + }, + "battery_cycles_since_reset": { + "name": "Cicli batteria dall'ultimo reset" + }, + "battery_cycles_reset_at": { + "name": "Ultimo reset cicli batteria" + }, + "blade_runtime_total": { + "name": "Tempo di funzionamento lame (totale)" + }, + "blade_runtime_current": { + "name": "Tempo di funzionamento lame (attuale)" + }, + "blade_runtime_reset_at": { + "name": "Ultimo reset tempo lame" + }, + "mower_runtime_total": { + "name": "Tempo di funzionamento totale" + }, + "mower_home_time_total": { + "name": "Tempo totale in base" + }, + "mower_charging_time_total": { + "name": "Tempo totale in carica" + }, + "mower_error_time_total": { + "name": "Tempo totale in errore" + }, + "maintenance_status": { + "name": "Stato manutenzione", + "state": { + "ok": "OK", + "blade_service_due": "Manutenzione lame necessaria", + "battery_service_due": "Manutenzione batteria necessaria" + } + }, + "pitch": { + "name": "Beccheggio" + }, + "roll": { + "name": "Rollio" + }, + "yaw": { + "name": "Imbardata" + }, + "last_update": { + "name": "Ultimo aggiornamento" + }, + "last_update_age": { + "name": "Tempo dall'ultimo aggiornamento" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT registrato" + }, + "mqtt_registered": { + "name": "MQTT registrato" + }, + "locked": { + "name": "Bloccato" + }, + "rain_triggered": { + "name": "Pioggia rilevata" + }, + "robot_lifted": { + "name": "Robot sollevato" + }, + "off_limits_enabled": { + "name": "Zone vietate attive" + }, + "acs_enabled": { + "name": "ACS attivo" + }, + "party_mode_enabled": { + "name": "Modalità festa attiva" + }, + "pause_mode_enabled": { + "name": "Modalità pausa attiva" + }, + "smart_edge_cut": { + "name": "Taglio bordi intelligente" + }, + "save_hedgehogs": { + "name": "Salva i ricci" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Aggiornamento automatico firmware" + }, + "lock": { + "name": "Blocco" + }, + "native_schedule": { + "name": "Programma nativo" + }, + "smart_edge_cut": { + "name": "Taglio bordi intelligente" + }, + "save_hedgehogs": { + "name": "Salva i ricci" + }, + "one_time_mowing_edge_cut": { + "name": "Taglio bordi" + } + }, + "button": { + "refresh": { + "name": "Aggiorna" + }, + "reset_blade_counter": { + "name": "Reimposta tempo lame" + }, + "reset_battery_cycle_counter": { + "name": "Reimposta cicli batteria" + }, + "start_edge_cut": { + "name": "Avvia taglio bordi" + }, + "start_one_time_mowing": { + "name": "Avvia taglio singolo" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Ritardo pioggia" + }, + "time_extension": { + "name": "Estensione tempo" + }, + "lawn_area": { + "name": "Superficie del prato" + }, + "lawn_perimeter": { + "name": "Perimetro del prato" + }, + "one_time_mowing_runtime": { + "name": "Durata taglio singolo" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Zona di taglio" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "Mappa RTK" + } + }, + "calendar": { + "schedule": { + "name": "Programma di taglio" + } + }, + "device_tracker": { + "rtk_position": { + "name": "Posizione RTK" + } + } + } +} diff --git a/custom_components/worx_vision_cloud/translations/nl.json b/custom_components/worx_vision_cloud/translations/nl.json new file mode 100644 index 0000000..1265885 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/nl.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Gebruik dezelfde inloggegevens als in de Worx Landroid-app.", + "data": { + "email": "E-mailadres", + "password": "Wachtwoord", + "cloud": "Cloud", + "verify_ssl": "SSL verifiëren", + "expose_raw_entities": "Alle ruwe payloadvelden als entiteiten weergeven" + } + } + }, + "error": { + "cannot_connect": "Kon geen verbinding maken met Worx Cloud.", + "invalid_auth": "Ongeldige gebruikersnaam of wachtwoord.", + "rate_limited": "Snelheidslimiet van Worx Cloud bereikt. Probeer het later opnieuw." + }, + "abort": { + "already_configured": "Dit account is al geconfigureerd." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batterij" + }, + "status": { + "name": "Status", + "state": { + "home": "In het laadstation", + "leaving_home": "Verlaat basisstation", + "going_home": "Terugkeer naar basisstation", + "mowing": "Maait", + "edge_cutting": "Randmaaien", + "charging": "Opladen", + "paused": "Gepauzeerd", + "idle": "Inactief", + "manual_stop": "Handmatig gestopt", + "rain_delay": "Regenvertraging", + "locked": "Vergrendeld", + "error": "Fout", + "no_error": "Geen fout", + "offline": "Offline" + } + }, + "error": { + "name": "Fout", + "state": { + "home": "In het laadstation", + "leaving_home": "Verlaat basisstation", + "going_home": "Terugkeer naar basisstation", + "mowing": "Maait", + "edge_cutting": "Randmaaien", + "charging": "Opladen", + "paused": "Gepauzeerd", + "idle": "Inactief", + "manual_stop": "Handmatig gestopt", + "rain_delay": "Regenvertraging", + "locked": "Vergrendeld", + "error": "Fout", + "no_error": "Geen fout", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Huidige zone" + }, + "schedule": { + "name": "Schema" + }, + "mowing_readiness": { + "name": "Maaigereedheid", + "state": { + "ready": "Gereed", + "mowing": "Maait", + "charging": "Opladen", + "battery_low": "Batterij bijna leeg", + "rain_delay": "Regenvertraging", + "error": "Fout", + "locked": "Vergrendeld", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Cloudverbinding", + "state": { + "ok": "Verbonden", + "check": "Controleren", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "API-mogelijkheden" + }, + "push_notifications": { + "name": "Pushmeldingen" + }, + "daily_progress": { + "name": "Dagvoortgang" + }, + "remaining_progress": { + "name": "Nog te maaien" + }, + "area_mowed_today": { + "name": "Vandaag gemaaid oppervlak" + }, + "next_schedule": { + "name": "Volgende maaibeurt" + }, + "area_mowed_total": { + "name": "Totaal gemaaid oppervlak" + }, + "lawn_area": { + "name": "Gazonoppervlak" + }, + "mowing_efficiency": { + "name": "Maai-efficiëntie" + }, + "rtk_map": { + "name": "RTK-kaart" + }, + "rtk_trail_points": { + "name": "RTK-spoorpunten" + }, + "rtk_address": { + "name": "RTK-adres" + }, + "rain_delay": { + "name": "Regenvertraging" + }, + "rain_remaining": { + "name": "Resterende regenvertraging" + }, + "battery_voltage": { + "name": "Batterijspanning" + }, + "battery_temperature": { + "name": "Batterijtemperatuur" + }, + "battery_cycles_total": { + "name": "Batterijcycli (totaal)" + }, + "battery_cycles_since_reset": { + "name": "Batterijcycli sinds reset" + }, + "battery_cycles_reset_at": { + "name": "Laatste reset batterijcycli" + }, + "blade_runtime_total": { + "name": "Meslooptijd (totaal)" + }, + "blade_runtime_current": { + "name": "Meslooptijd (huidig)" + }, + "blade_runtime_reset_at": { + "name": "Laatste reset meslooptijd" + }, + "mower_runtime_total": { + "name": "Totale looptijd" + }, + "mower_home_time_total": { + "name": "Totale tijd in basisstation" + }, + "mower_charging_time_total": { + "name": "Totale oplaadtijd" + }, + "mower_error_time_total": { + "name": "Totale tijd in fout" + }, + "maintenance_status": { + "name": "Onderhoudsstatus", + "state": { + "ok": "OK", + "blade_service_due": "Mesonderhoud nodig", + "battery_service_due": "Batterijonderhoud nodig" + } + }, + "pitch": { + "name": "Pitch" + }, + "roll": { + "name": "Roll" + }, + "yaw": { + "name": "Yaw" + }, + "last_update": { + "name": "Laatste update" + }, + "last_update_age": { + "name": "Tijd sinds laatste update" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT geregistreerd" + }, + "mqtt_registered": { + "name": "MQTT geregistreerd" + }, + "locked": { + "name": "Vergrendeld" + }, + "rain_triggered": { + "name": "Regen gedetecteerd" + }, + "robot_lifted": { + "name": "Robot opgetild" + }, + "off_limits_enabled": { + "name": "Verboden zones ingeschakeld" + }, + "acs_enabled": { + "name": "ACS ingeschakeld" + }, + "party_mode_enabled": { + "name": "Feestmodus ingeschakeld" + }, + "pause_mode_enabled": { + "name": "Pauzemodus ingeschakeld" + }, + "smart_edge_cut": { + "name": "Slim randmaaien" + }, + "save_hedgehogs": { + "name": "Egels redden" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Automatische firmware-update" + }, + "lock": { + "name": "Vergrendelen" + }, + "native_schedule": { + "name": "Native planning" + }, + "smart_edge_cut": { + "name": "Slim randmaaien" + }, + "save_hedgehogs": { + "name": "Egels redden" + }, + "one_time_mowing_edge_cut": { + "name": "Randmaaien" + } + }, + "button": { + "refresh": { + "name": "Vernieuwen" + }, + "reset_blade_counter": { + "name": "Meslooptijd resetten" + }, + "reset_battery_cycle_counter": { + "name": "Batterijcycli resetten" + }, + "start_edge_cut": { + "name": "Randmaaien starten" + }, + "start_one_time_mowing": { + "name": "Eenmalig maaien starten" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Regenvertraging" + }, + "time_extension": { + "name": "Tijdsverlenging" + }, + "lawn_area": { + "name": "Gazonoppervlak" + }, + "lawn_perimeter": { + "name": "Gazonomtrek" + }, + "one_time_mowing_runtime": { + "name": "Duur eenmalig maaien" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Maaizone" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "RTK-kaart" + } + }, + "calendar": { + "schedule": { + "name": "Maaischema" + } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK-positie" + } + } + } +} diff --git a/custom_components/worx_vision_cloud/translations/no.json b/custom_components/worx_vision_cloud/translations/no.json new file mode 100644 index 0000000..ba1abcb --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/no.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Bruk samme innlogging som i Worx Landroid-appen.", + "data": { + "email": "E-post", + "password": "Passord", + "cloud": "Sky", + "verify_ssl": "Bekreft SSL", + "expose_raw_entities": "Eksponer alle rå datafelt som enheter" + } + } + }, + "error": { + "cannot_connect": "Kunne ikke koble til Worx Cloud.", + "invalid_auth": "Ugyldig brukernavn eller passord.", + "rate_limited": "Worx Cloud-forespørselsgrense nådd. Prøv igjen senere." + }, + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batteri" + }, + "status": { + "name": "Status", + "state": { + "home": "Ved basestasjonen", + "leaving_home": "Forlater basestasjonen", + "going_home": "På vei til basestasjonen", + "mowing": "Klipper", + "edge_cutting": "Kantklipping", + "charging": "Lader", + "paused": "Pause", + "idle": "Inaktiv", + "manual_stop": "Manuelt stoppet", + "rain_delay": "Regnforsinkelse", + "locked": "Låst", + "error": "Feil", + "no_error": "Ingen feil", + "offline": "Offline" + } + }, + "error": { + "name": "Feil", + "state": { + "home": "Ved basestasjonen", + "leaving_home": "Forlater basestasjonen", + "going_home": "På vei til basestasjonen", + "mowing": "Klipper", + "edge_cutting": "Kantklipping", + "charging": "Lader", + "paused": "Pause", + "idle": "Inaktiv", + "manual_stop": "Manuelt stoppet", + "rain_delay": "Regnforsinkelse", + "locked": "Låst", + "error": "Feil", + "no_error": "Ingen feil", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Nåværende sone" + }, + "schedule": { + "name": "Tidsplan" + }, + "mowing_readiness": { + "name": "Klippeklarhet", + "state": { + "ready": "Klar", + "mowing": "Klipper", + "charging": "Lader", + "battery_low": "Lavt batterinivå", + "rain_delay": "Regnforsinkelse", + "error": "Feil", + "locked": "Låst", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Skytilkobling", + "state": { + "ok": "Tilkoblet", + "check": "Sjekker", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "API-funksjoner" + }, + "push_notifications": { + "name": "Push-varsler" + }, + "daily_progress": { + "name": "Dagens fremgang" + }, + "remaining_progress": { + "name": "Gjenstår å klippe" + }, + "area_mowed_today": { + "name": "Klippet areal i dag" + }, + "next_schedule": { + "name": "Neste klipping" + }, + "area_mowed_total": { + "name": "Totalt klippet areal" + }, + "lawn_area": { + "name": "Plenareal" + }, + "mowing_efficiency": { + "name": "Klippeeffektivitet" + }, + "rtk_map": { + "name": "RTK-kart" + }, + "rtk_trail_points": { + "name": "RTK-sporpunkter" + }, + "rtk_address": { + "name": "RTK-adresse" + }, + "rain_delay": { + "name": "Regnforsinkelse" + }, + "rain_remaining": { + "name": "Gjenværende regnforsinkelse" + }, + "battery_voltage": { + "name": "Batterispenning" + }, + "battery_temperature": { + "name": "Batteritemperatur" + }, + "battery_cycles_total": { + "name": "Batterisykluser (totalt)" + }, + "battery_cycles_since_reset": { + "name": "Batterisykluser siden tilbakestilling" + }, + "battery_cycles_reset_at": { + "name": "Siste tilbakestilling av batterisykluser" + }, + "blade_runtime_total": { + "name": "Knivenes driftstid (totalt)" + }, + "blade_runtime_current": { + "name": "Knivenes driftstid (nåværende)" + }, + "blade_runtime_reset_at": { + "name": "Siste tilbakestilling av knivtid" + }, + "mower_runtime_total": { + "name": "Total driftstid" + }, + "mower_home_time_total": { + "name": "Total tid ved basestasjonen" + }, + "mower_charging_time_total": { + "name": "Total ladetid" + }, + "mower_error_time_total": { + "name": "Total tid i feil" + }, + "maintenance_status": { + "name": "Vedlikeholdsstatus", + "state": { + "ok": "OK", + "blade_service_due": "Knivservice påkrevd", + "battery_service_due": "Batteriservice påkrevd" + } + }, + "pitch": { + "name": "Pitch" + }, + "roll": { + "name": "Roll" + }, + "yaw": { + "name": "Yaw" + }, + "last_update": { + "name": "Siste oppdatering" + }, + "last_update_age": { + "name": "Tid siden siste oppdatering" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT registrert" + }, + "mqtt_registered": { + "name": "MQTT registrert" + }, + "locked": { + "name": "Låst" + }, + "rain_triggered": { + "name": "Regn oppdaget" + }, + "robot_lifted": { + "name": "Robot løftet" + }, + "off_limits_enabled": { + "name": "Forbudte soner aktivert" + }, + "acs_enabled": { + "name": "ACS aktivert" + }, + "party_mode_enabled": { + "name": "Festmodus aktivert" + }, + "pause_mode_enabled": { + "name": "Pausemodus aktivert" + }, + "smart_edge_cut": { + "name": "Smart kantklipping" + }, + "save_hedgehogs": { + "name": "Redd pinnsvinene" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Automatisk fastvareoppdatering" + }, + "lock": { + "name": "Lås" + }, + "native_schedule": { + "name": "Innebygd tidsplan" + }, + "smart_edge_cut": { + "name": "Smart kantklipping" + }, + "save_hedgehogs": { + "name": "Redd pinnsvinene" + }, + "one_time_mowing_edge_cut": { + "name": "Kantklipping" + } + }, + "button": { + "refresh": { + "name": "Oppdater" + }, + "reset_blade_counter": { + "name": "Tilbakestill knivtid" + }, + "reset_battery_cycle_counter": { + "name": "Tilbakestill batterisykluser" + }, + "start_edge_cut": { + "name": "Start kantklipping" + }, + "start_one_time_mowing": { + "name": "Start engangsklipping" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Regnforsinkelse" + }, + "time_extension": { + "name": "Tidsforlengelse" + }, + "lawn_area": { + "name": "Plenareal" + }, + "lawn_perimeter": { + "name": "Plenomkrets" + }, + "one_time_mowing_runtime": { + "name": "Varighet for engangsklipping" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Klippesone" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "RTK-kart" + } + }, + "calendar": { + "schedule": { + "name": "Klippeplan" + } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK-posisjon" + } + } + } +} diff --git a/custom_components/worx_vision_cloud/translations/sv.json b/custom_components/worx_vision_cloud/translations/sv.json new file mode 100644 index 0000000..f4a0dc5 --- /dev/null +++ b/custom_components/worx_vision_cloud/translations/sv.json @@ -0,0 +1,319 @@ +{ + "title": "Worx Vision Cloud PLUS", + "config": { + "step": { + "user": { + "title": "Worx Vision Cloud PLUS", + "description": "Använd samma inloggning som i Worx Landroid-appen.", + "data": { + "email": "E-post", + "password": "Lösenord", + "cloud": "Moln", + "verify_ssl": "Verifiera SSL", + "expose_raw_entities": "Exponera alla råa payload-fält som entiteter" + } + } + }, + "error": { + "cannot_connect": "Det gick inte att ansluta till Worx Cloud.", + "invalid_auth": "Ogiltigt användarnamn eller lösenord.", + "rate_limited": "Gränsen för Worx Cloud-förfrågningar nådd. Försök igen senare." + }, + "abort": { + "already_configured": "Det här kontot är redan konfigurerat." + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Batteri" + }, + "status": { + "name": "Status", + "state": { + "home": "Vid basstationen", + "leaving_home": "Lämnar basstationen", + "going_home": "Återvänder till basstationen", + "mowing": "Klipper", + "edge_cutting": "Kantklippning", + "charging": "Laddar", + "paused": "Pausad", + "idle": "Inaktiv", + "manual_stop": "Manuellt stoppad", + "rain_delay": "Regnfördröjning", + "locked": "Låst", + "error": "Fel", + "no_error": "Inget fel", + "offline": "Offline" + } + }, + "error": { + "name": "Fel", + "state": { + "home": "Vid basstationen", + "leaving_home": "Lämnar basstationen", + "going_home": "Återvänder till basstationen", + "mowing": "Klipper", + "edge_cutting": "Kantklippning", + "charging": "Laddar", + "paused": "Pausad", + "idle": "Inaktiv", + "manual_stop": "Manuellt stoppad", + "rain_delay": "Regnfördröjning", + "locked": "Låst", + "error": "Fel", + "no_error": "Inget fel", + "offline": "Offline" + } + }, + "rssi": { + "name": "RSSI" + }, + "zone_current": { + "name": "Aktuell zon" + }, + "schedule": { + "name": "Schema" + }, + "mowing_readiness": { + "name": "Klippberedskap", + "state": { + "ready": "Redo", + "mowing": "Klipper", + "charging": "Laddar", + "battery_low": "Låg batterinivå", + "rain_delay": "Regnfördröjning", + "error": "Fel", + "locked": "Låst", + "offline": "Offline" + } + }, + "cloud_connection": { + "name": "Molnanslutning", + "state": { + "ok": "Ansluten", + "check": "Kontrollerar", + "offline": "Offline" + } + }, + "api_capabilities": { + "name": "API-funktioner" + }, + "push_notifications": { + "name": "Push-aviseringar" + }, + "daily_progress": { + "name": "Dagens förlopp" + }, + "remaining_progress": { + "name": "Kvar att klippa" + }, + "area_mowed_today": { + "name": "Klippt yta idag" + }, + "next_schedule": { + "name": "Nästa klippning" + }, + "area_mowed_total": { + "name": "Total klippt yta" + }, + "lawn_area": { + "name": "Gräsmattans yta" + }, + "mowing_efficiency": { + "name": "Klippeffektivitet" + }, + "rtk_map": { + "name": "RTK-karta" + }, + "rtk_trail_points": { + "name": "RTK-spårpunkter" + }, + "rtk_address": { + "name": "RTK-adress" + }, + "rain_delay": { + "name": "Regnfördröjning" + }, + "rain_remaining": { + "name": "Återstående regnfördröjning" + }, + "battery_voltage": { + "name": "Batterispänning" + }, + "battery_temperature": { + "name": "Batteritemperatur" + }, + "battery_cycles_total": { + "name": "Battericykler (totalt)" + }, + "battery_cycles_since_reset": { + "name": "Battericykler sedan återställning" + }, + "battery_cycles_reset_at": { + "name": "Senaste återställning av battericykler" + }, + "blade_runtime_total": { + "name": "Knivarnas gångtid (totalt)" + }, + "blade_runtime_current": { + "name": "Knivarnas gångtid (aktuell)" + }, + "blade_runtime_reset_at": { + "name": "Senaste återställning av knivtid" + }, + "mower_runtime_total": { + "name": "Total gångtid" + }, + "mower_home_time_total": { + "name": "Total tid vid basstationen" + }, + "mower_charging_time_total": { + "name": "Total laddningstid" + }, + "mower_error_time_total": { + "name": "Total tid i fel" + }, + "maintenance_status": { + "name": "Underhållsstatus", + "state": { + "ok": "OK", + "blade_service_due": "Knivservice krävs", + "battery_service_due": "Batteriservice krävs" + } + }, + "pitch": { + "name": "Pitch" + }, + "roll": { + "name": "Roll" + }, + "yaw": { + "name": "Yaw" + }, + "last_update": { + "name": "Senaste uppdatering" + }, + "last_update_age": { + "name": "Tid sedan senaste uppdatering" + } + }, + "binary_sensor": { + "online": { + "name": "Online" + }, + "iot_registered": { + "name": "IoT registrerad" + }, + "mqtt_registered": { + "name": "MQTT registrerad" + }, + "locked": { + "name": "Låst" + }, + "rain_triggered": { + "name": "Regn upptäckt" + }, + "robot_lifted": { + "name": "Robot upplyft" + }, + "off_limits_enabled": { + "name": "Förbjudna zoner aktiverade" + }, + "acs_enabled": { + "name": "ACS aktiverat" + }, + "party_mode_enabled": { + "name": "Festläge aktiverat" + }, + "pause_mode_enabled": { + "name": "Pausläge aktiverat" + }, + "smart_edge_cut": { + "name": "Smart kantklippning" + }, + "save_hedgehogs": { + "name": "Rädda igelkottarna" + } + }, + "switch": { + "firmware_auto_upgrade": { + "name": "Automatisk firmware-uppdatering" + }, + "lock": { + "name": "Lås" + }, + "native_schedule": { + "name": "Inbyggt schema" + }, + "smart_edge_cut": { + "name": "Smart kantklippning" + }, + "save_hedgehogs": { + "name": "Rädda igelkottarna" + }, + "one_time_mowing_edge_cut": { + "name": "Kantklippning" + } + }, + "button": { + "refresh": { + "name": "Uppdatera" + }, + "reset_blade_counter": { + "name": "Återställ knivtid" + }, + "reset_battery_cycle_counter": { + "name": "Återställ battericykler" + }, + "start_edge_cut": { + "name": "Starta kantklippning" + }, + "start_one_time_mowing": { + "name": "Starta engångsklippning" + } + }, + "number": { + "rain_delay_minutes": { + "name": "Regnfördröjning" + }, + "time_extension": { + "name": "Tidsförlängning" + }, + "lawn_area": { + "name": "Gräsmattans yta" + }, + "lawn_perimeter": { + "name": "Gräsmattans omkrets" + }, + "one_time_mowing_runtime": { + "name": "Varaktighet för engångsklippning" + } + }, + "select": { + "one_time_mowing_zones": { + "name": "Klippzon" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + }, + "camera": { + "rtk_map_camera": { + "name": "RTK-karta" + } + }, + "calendar": { + "schedule": { + "name": "Klippschema" + } + }, + "device_tracker": { + "rtk_position": { + "name": "RTK-position" + } + } + } +} From b133122d55560cbc26c15ad8267545447a9d876a Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:46:51 +0200 Subject: [PATCH 15/21] Docs: list all 10 supported languages --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8907df3..76aabc1 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ If this integration helps you, you can support Smart Service: - Switches for Smart edge cutting, Save the hedgehogs and schedule edge procedure. - 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, English, French and German translations, including localized entity states, schedule and calendar. +- 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 From 1d21aea38de7a1b29676d743e4f19422b2ba319f Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:27:19 +0200 Subject: [PATCH 16/21] Fix: always_update=True so REST-enriched data actually reaches entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _device_map() returns the same DeviceHandler instances pyworxcloud already holds, and _enrich_device() mutates them in place (product-item area_mowed, firmware info, RTK map). With always_update=False the DataUpdateCoordinator skips notifying listeners whenever the returned data compares equal to the previous data — which is always true here, since the dict contains the same object references before and after, regardless of what changed on them. This silently prevented entities like area_mowed_total (and the daily progress/remaining sensors derived from it) from ever refreshing outside of MQTT push updates, which don't carry the REST-only product-item fields. Confirmed live: pressing the refresh button did not update last_updated at all before this fix, despite the coordinator's REST refresh path running. --- custom_components/worx_vision_cloud/coordinator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/worx_vision_cloud/coordinator.py b/custom_components/worx_vision_cloud/coordinator.py index b028e38..7d4b84a 100644 --- a/custom_components/worx_vision_cloud/coordinator.py +++ b/custom_components/worx_vision_cloud/coordinator.py @@ -68,7 +68,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() From 94e4cf2430219f580644e7a9428527edda435baa Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:38:54 +0200 Subject: [PATCH 17/21] Log REST enrichment failures and results for diagnostics _refresh_from_cloud_cache() previously swallowed all exceptions from _enrich_device() via asyncio.gather(return_exceptions=True) with no logging at all, making it impossible to tell whether the REST refresh path (area_mowed, firmware, RTK map) was actually running, failing silently, or genuinely returning stale data from Worx's own API. Now logs a warning per device on failure, and a debug line with the fetched area_mowed value on success, to diagnose why Total area mowed appeared to only ever refresh at integration startup/reload. --- .../worx_vision_cloud/coordinator.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/custom_components/worx_vision_cloud/coordinator.py b/custom_components/worx_vision_cloud/coordinator.py index 7d4b84a..29756cb 100644 --- a/custom_components/worx_vision_cloud/coordinator.py +++ b/custom_components/worx_vision_cloud/coordinator.py @@ -125,10 +125,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]: @@ -757,6 +765,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: From 00bf8d820f75063794c61277e26582cc6bb460a4 Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:14:07 +0200 Subject: [PATCH 18/21] Add estimated area mowed today sensor (works around REST staleness) area_mowed only refreshes when Worx's REST product-item endpoint reports a new figure, which can lag for hours during active mowing (confirmed: the endpoint call succeeds and Worx genuinely returns the same stale value for hours). Add a new sensor that estimates today's coverage from today's work time (live via MQTT statistics, delta from a local-midnight baseline like the other daily sensors) multiplied by the mower's average mowing efficiency (m2/h). This moves during the day even when Total/Today area mowed are stuck waiting for Worx to recompute the real figure. Also factor out _work_time_total_minutes() (statistics-preferring, REST fallback) instead of duplicating that logic inline in mower_runtime_total. _mowing_efficiency() deliberately keeps using the REST-only work_time to stay paired with the REST-only area_mowed_total from the same snapshot. Translation added to all 10 languages (224 keys each, validated). --- custom_components/worx_vision_cloud/sensor.py | 104 +++++++++++++++++- .../worx_vision_cloud/translations/da.json | 3 + .../worx_vision_cloud/translations/de.json | 3 + .../worx_vision_cloud/translations/en.json | 3 + .../worx_vision_cloud/translations/es.json | 3 + .../worx_vision_cloud/translations/fr.json | 3 + .../worx_vision_cloud/translations/it.json | 3 + .../worx_vision_cloud/translations/nl.json | 3 + .../worx_vision_cloud/translations/no.json | 3 + .../worx_vision_cloud/translations/pl.json | 3 + .../worx_vision_cloud/translations/sv.json | 3 + 11 files changed, 132 insertions(+), 2 deletions(-) diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index 40d8b4c..8aad472 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -290,7 +290,23 @@ def _since_reset(device, total_key: str, reset_key: str) -> int | None: return max(0, total - reset) +def _work_time_total_minutes(device) -> float | None: + """Return the lifetime mower work time in minutes. + + Prefers the MQTT-pushed statistics value (updates live while mowing) and + falls back to the REST product-item field when statistics are unavailable. + """ + value = _statistics(device, "worktime_total") + if value is None: + value = _product_item(device, "mower_work_time") + return _as_float(value) + + def _mowing_efficiency(device) -> float | None: + # Deliberately pairs area with the REST-only work_time (not + # _work_time_total_minutes' live-preferring value): both figures come from + # the same product-item snapshot, so the ratio stays internally consistent + # even though it only refreshes as often as that REST endpoint does. area = _area_mowed_total(device) work_minutes = _as_float(_product_item(device, "mower_work_time")) if area is None or work_minutes in (None, 0): @@ -826,8 +842,7 @@ def _rtk_address_attributes( device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda d: _statistics(d, "worktime_total") - or _product_item(d, "mower_work_time"), + value_fn=_work_time_total_minutes, ), WorxSensorDescription( key="mower_home_time_total", @@ -940,6 +955,7 @@ async def async_setup_entry( entities.append(WorxAreaMowedTodaySensor(coordinator, entry, serial_number)) entities.append(WorxDailyProgressSensor(coordinator, entry, serial_number)) entities.append(WorxRemainingProgressSensor(coordinator, entry, serial_number)) + entities.append(WorxEstimatedAreaTodaySensor(coordinator, entry, serial_number)) def add_raw_entities() -> None: raw_entities: list[SensorEntity] = [] @@ -1170,6 +1186,90 @@ def native_value(self) -> float | None: return round(max(0, 100 - progress), 1) +class _WorxDailyRuntimeBase(WorxVisionEntity, RestoreSensor): + """Base for sensors derived from mower work time since local midnight. + + Unlike area_mowed (REST-only, can go stale for hours), work time is + live-pushed via MQTT statistics, so a delta-from-midnight baseline here + tracks today's runtime in near real time. + """ + + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator, entry, serial_number: str, key: str) -> None: + """Initialize the daily runtime base sensor.""" + super().__init__(coordinator, entry, serial_number, key) + self._baseline_total: float | None = None + self._baseline_date: str | None = None + + async def async_added_to_hass(self) -> None: + """Restore the saved daily baseline.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is None: + return + try: + self._baseline_total = float(last_state.attributes["baseline_total"]) + except (KeyError, TypeError, ValueError): + self._baseline_total = None + self._baseline_date = last_state.attributes.get("baseline_date") + + def _today_runtime_minutes(self) -> float | None: + """Return mower work time since local midnight (minutes).""" + total = _work_time_total_minutes(self.device) + if total is None: + return None + today = dt_util.now().date().isoformat() + if ( + self._baseline_total is None + or self._baseline_date != today + or total < self._baseline_total + ): + self._baseline_total = total + self._baseline_date = today + return round(max(0.0, total - self._baseline_total), 2) + + +class WorxEstimatedAreaTodaySensor(_WorxDailyRuntimeBase): + """Estimated area mowed today, from today's runtime and average efficiency. + + area_mowed only refreshes when Worx's REST product-item endpoint reports a + new figure, which can lag for hours during active mowing. This sensor + estimates today's coverage instead as today's work time (live) multiplied + by the mower's average mowing efficiency (m2/h, itself REST-sourced but + changes slowly), so it moves during the day even when Total/Today area + mowed are stuck waiting for Worx to recompute the real figure. + """ + + _attr_translation_key = "estimated_area_mowed_today" + _attr_device_class = SensorDeviceClass.AREA + _attr_native_unit_of_measurement = UnitOfArea.SQUARE_METERS + _attr_icon = "mdi:grass" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize estimated area mowed today.""" + super().__init__(coordinator, entry, serial_number, "estimated_area_mowed_today") + + @property + def native_value(self) -> float | None: + """Return today's estimated mowed area.""" + runtime_minutes = self._today_runtime_minutes() + efficiency = _mowing_efficiency(self.device) + if runtime_minutes is None or efficiency is None: + return None + return round(runtime_minutes / 60 * efficiency, 2) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the baseline plus the figures used for the estimate.""" + return { + "baseline_total": self._baseline_total, + "baseline_date": self._baseline_date, + "runtime_today_minutes": self._today_runtime_minutes(), + "mowing_efficiency": _mowing_efficiency(self.device), + } + + class WorxVisionAddressSensor(WorxVisionEntity, SensorEntity): """Reverse-geocoded RTK address sensor.""" diff --git a/custom_components/worx_vision_cloud/translations/da.json b/custom_components/worx_vision_cloud/translations/da.json index fb29e62..16bb1b2 100644 --- a/custom_components/worx_vision_cloud/translations/da.json +++ b/custom_components/worx_vision_cloud/translations/da.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Samlet slået areal" }, + "estimated_area_mowed_today": { + "name": "Estimeret slået areal i dag" + }, "lawn_area": { "name": "Plænens areal" }, diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json index 5ce6383..da237ca 100644 --- a/custom_components/worx_vision_cloud/translations/de.json +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Insgesamt gemähte Fläche" }, + "estimated_area_mowed_today": { + "name": "Geschätzte heute gemähte Fläche" + }, "lawn_area": { "name": "Rasenfläche" }, diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index d07b936..79abe25 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Total area mowed" }, + "estimated_area_mowed_today": { + "name": "Estimated area mowed today" + }, "lawn_area": { "name": "Lawn area" }, diff --git a/custom_components/worx_vision_cloud/translations/es.json b/custom_components/worx_vision_cloud/translations/es.json index 74099e6..4aadacf 100644 --- a/custom_components/worx_vision_cloud/translations/es.json +++ b/custom_components/worx_vision_cloud/translations/es.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Superficie total cortada" }, + "estimated_area_mowed_today": { + "name": "Superficie estimada cortada hoy" + }, "lawn_area": { "name": "Superficie del césped" }, diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index 0d66cd4..45aa6b5 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Surface totale tondue" }, + "estimated_area_mowed_today": { + "name": "Surface estimée tondue aujourd'hui" + }, "lawn_area": { "name": "Surface de la pelouse" }, diff --git a/custom_components/worx_vision_cloud/translations/it.json b/custom_components/worx_vision_cloud/translations/it.json index 508086c..82d5ed2 100644 --- a/custom_components/worx_vision_cloud/translations/it.json +++ b/custom_components/worx_vision_cloud/translations/it.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Superficie totale tagliata" }, + "estimated_area_mowed_today": { + "name": "Superficie stimata tagliata oggi" + }, "lawn_area": { "name": "Superficie del prato" }, diff --git a/custom_components/worx_vision_cloud/translations/nl.json b/custom_components/worx_vision_cloud/translations/nl.json index 1265885..9b25875 100644 --- a/custom_components/worx_vision_cloud/translations/nl.json +++ b/custom_components/worx_vision_cloud/translations/nl.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Totaal gemaaid oppervlak" }, + "estimated_area_mowed_today": { + "name": "Geschatte vandaag gemaaid oppervlak" + }, "lawn_area": { "name": "Gazonoppervlak" }, diff --git a/custom_components/worx_vision_cloud/translations/no.json b/custom_components/worx_vision_cloud/translations/no.json index ba1abcb..8f858f0 100644 --- a/custom_components/worx_vision_cloud/translations/no.json +++ b/custom_components/worx_vision_cloud/translations/no.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Totalt klippet areal" }, + "estimated_area_mowed_today": { + "name": "Estimert klippet areal i dag" + }, "lawn_area": { "name": "Plenareal" }, diff --git a/custom_components/worx_vision_cloud/translations/pl.json b/custom_components/worx_vision_cloud/translations/pl.json index 1cb3588..6681008 100644 --- a/custom_components/worx_vision_cloud/translations/pl.json +++ b/custom_components/worx_vision_cloud/translations/pl.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Łącznie skoszona powierzchnia" }, + "estimated_area_mowed_today": { + "name": "Szacowana powierzchnia skoszona dziś" + }, "lawn_area": { "name": "Powierzchnia trawnika" }, diff --git a/custom_components/worx_vision_cloud/translations/sv.json b/custom_components/worx_vision_cloud/translations/sv.json index f4a0dc5..e1a7500 100644 --- a/custom_components/worx_vision_cloud/translations/sv.json +++ b/custom_components/worx_vision_cloud/translations/sv.json @@ -117,6 +117,9 @@ "area_mowed_total": { "name": "Total klippt yta" }, + "estimated_area_mowed_today": { + "name": "Uppskattad klippt yta idag" + }, "lawn_area": { "name": "Gräsmattans yta" }, From 0af35d4d7a9f0a832804d8dc6616ae4235b15ead Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:15:54 +0200 Subject: [PATCH 19/21] Add periodic device refresh and estimated daily progress sensor Confirmed live: pressing the Refresh button asks the mower to publish a fresh MQTT payload (including work-time statistics), which updates the estimated area/progress sensors, but passive push updates during active mowing do not reliably include those statistics fields. Manually clicking Refresh every time is impractical, so the coordinator now asks every known device to report in automatically every 5 minutes (LIVE_REFRESH_INTERVAL), same effect as the button, with proper subscribe/unsubscribe on setup/shutdown. Also add 'Estimated daily progress': same today's-runtime x average- efficiency estimate as Estimated area mowed today, expressed as a percentage of the known lawn area, so progress is visible during the day even when the REST-based daily progress sensor is stuck. Translation added to all 10 languages (226 keys each, validated). --- .../worx_vision_cloud/coordinator.py | 32 +++++++++++- custom_components/worx_vision_cloud/sensor.py | 50 +++++++++++++++++-- .../worx_vision_cloud/translations/da.json | 3 ++ .../worx_vision_cloud/translations/de.json | 3 ++ .../worx_vision_cloud/translations/en.json | 3 ++ .../worx_vision_cloud/translations/es.json | 3 ++ .../worx_vision_cloud/translations/fr.json | 3 ++ .../worx_vision_cloud/translations/it.json | 3 ++ .../worx_vision_cloud/translations/nl.json | 3 ++ .../worx_vision_cloud/translations/no.json | 3 ++ .../worx_vision_cloud/translations/pl.json | 3 ++ .../worx_vision_cloud/translations/sv.json | 3 ++ 12 files changed, 107 insertions(+), 5 deletions(-) diff --git a/custom_components/worx_vision_cloud/coordinator.py b/custom_components/worx_vision_cloud/coordinator.py index 29756cb..f8f814e 100644 --- a/custom_components/worx_vision_cloud/coordinator.py +++ b/custom_components/worx_vision_cloud/coordinator.py @@ -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 @@ -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 @@ -90,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.""" @@ -104,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.""" diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index 8aad472..edc0dc0 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -956,6 +956,7 @@ async def async_setup_entry( entities.append(WorxDailyProgressSensor(coordinator, entry, serial_number)) entities.append(WorxRemainingProgressSensor(coordinator, entry, serial_number)) entities.append(WorxEstimatedAreaTodaySensor(coordinator, entry, serial_number)) + entities.append(WorxEstimatedDailyProgressSensor(coordinator, entry, serial_number)) def add_raw_entities() -> None: raw_entities: list[SensorEntity] = [] @@ -1235,10 +1236,12 @@ class WorxEstimatedAreaTodaySensor(_WorxDailyRuntimeBase): area_mowed only refreshes when Worx's REST product-item endpoint reports a new figure, which can lag for hours during active mowing. This sensor - estimates today's coverage instead as today's work time (live) multiplied - by the mower's average mowing efficiency (m2/h, itself REST-sourced but - changes slowly), so it moves during the day even when Total/Today area - mowed are stuck waiting for Worx to recompute the real figure. + estimates today's coverage instead as today's work time (refreshed on the + coordinator's periodic device-update cadence, not just at session end) + multiplied by the mower's average mowing efficiency (m2/h, itself + REST-sourced but changes slowly), so it moves during the day even when + Total/Today area mowed are stuck waiting for Worx to recompute the real + figure. """ _attr_translation_key = "estimated_area_mowed_today" @@ -1270,6 +1273,45 @@ def extra_state_attributes(self) -> dict[str, Any]: } +class WorxEstimatedDailyProgressSensor(_WorxDailyRuntimeBase): + """Estimated percentage of the lawn mowed today. + + Same estimate as WorxEstimatedAreaTodaySensor (today's runtime x average + efficiency), expressed as a percentage of the known lawn area, so it + moves during the day even when the REST-based daily progress is stuck. + """ + + _attr_translation_key = "estimated_daily_progress" + _attr_native_unit_of_measurement = PERCENTAGE + _attr_icon = "mdi:progress-check" + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize estimated daily progress.""" + super().__init__(coordinator, entry, serial_number, "estimated_daily_progress") + + @property + def native_value(self) -> float | None: + """Return today's estimated progress in percent.""" + runtime_minutes = self._today_runtime_minutes() + efficiency = _mowing_efficiency(self.device) + lawn_area = _lawn_area(self.device) + if runtime_minutes is None or efficiency is None or lawn_area in (None, 0): + return None + estimated_area = runtime_minutes / 60 * efficiency + return round(max(0, min(100, estimated_area / lawn_area * 100)), 1) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the baseline plus the figures used for the estimate.""" + return { + "baseline_total": self._baseline_total, + "baseline_date": self._baseline_date, + "runtime_today_minutes": self._today_runtime_minutes(), + "mowing_efficiency": _mowing_efficiency(self.device), + "lawn_area": _lawn_area(self.device), + } + + class WorxVisionAddressSensor(WorxVisionEntity, SensorEntity): """Reverse-geocoded RTK address sensor.""" diff --git a/custom_components/worx_vision_cloud/translations/da.json b/custom_components/worx_vision_cloud/translations/da.json index 16bb1b2..bc10920 100644 --- a/custom_components/worx_vision_cloud/translations/da.json +++ b/custom_components/worx_vision_cloud/translations/da.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Estimeret slået areal i dag" }, + "estimated_daily_progress": { + "name": "Estimeret dagligt fremskridt" + }, "lawn_area": { "name": "Plænens areal" }, diff --git a/custom_components/worx_vision_cloud/translations/de.json b/custom_components/worx_vision_cloud/translations/de.json index da237ca..32306e8 100644 --- a/custom_components/worx_vision_cloud/translations/de.json +++ b/custom_components/worx_vision_cloud/translations/de.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Geschätzte heute gemähte Fläche" }, + "estimated_daily_progress": { + "name": "Geschätzter Tagesfortschritt" + }, "lawn_area": { "name": "Rasenfläche" }, diff --git a/custom_components/worx_vision_cloud/translations/en.json b/custom_components/worx_vision_cloud/translations/en.json index 79abe25..597143f 100644 --- a/custom_components/worx_vision_cloud/translations/en.json +++ b/custom_components/worx_vision_cloud/translations/en.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Estimated area mowed today" }, + "estimated_daily_progress": { + "name": "Estimated daily progress" + }, "lawn_area": { "name": "Lawn area" }, diff --git a/custom_components/worx_vision_cloud/translations/es.json b/custom_components/worx_vision_cloud/translations/es.json index 4aadacf..1bb3910 100644 --- a/custom_components/worx_vision_cloud/translations/es.json +++ b/custom_components/worx_vision_cloud/translations/es.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Superficie estimada cortada hoy" }, + "estimated_daily_progress": { + "name": "Progreso diario estimado" + }, "lawn_area": { "name": "Superficie del césped" }, diff --git a/custom_components/worx_vision_cloud/translations/fr.json b/custom_components/worx_vision_cloud/translations/fr.json index 45aa6b5..98ca3e7 100644 --- a/custom_components/worx_vision_cloud/translations/fr.json +++ b/custom_components/worx_vision_cloud/translations/fr.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Surface estimée tondue aujourd'hui" }, + "estimated_daily_progress": { + "name": "Progression quotidienne estimée" + }, "lawn_area": { "name": "Surface de la pelouse" }, diff --git a/custom_components/worx_vision_cloud/translations/it.json b/custom_components/worx_vision_cloud/translations/it.json index 82d5ed2..25b544d 100644 --- a/custom_components/worx_vision_cloud/translations/it.json +++ b/custom_components/worx_vision_cloud/translations/it.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Superficie stimata tagliata oggi" }, + "estimated_daily_progress": { + "name": "Avanzamento giornaliero stimato" + }, "lawn_area": { "name": "Superficie del prato" }, diff --git a/custom_components/worx_vision_cloud/translations/nl.json b/custom_components/worx_vision_cloud/translations/nl.json index 9b25875..f4de316 100644 --- a/custom_components/worx_vision_cloud/translations/nl.json +++ b/custom_components/worx_vision_cloud/translations/nl.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Geschatte vandaag gemaaid oppervlak" }, + "estimated_daily_progress": { + "name": "Geschatte dagvoortgang" + }, "lawn_area": { "name": "Gazonoppervlak" }, diff --git a/custom_components/worx_vision_cloud/translations/no.json b/custom_components/worx_vision_cloud/translations/no.json index 8f858f0..b9bb0c0 100644 --- a/custom_components/worx_vision_cloud/translations/no.json +++ b/custom_components/worx_vision_cloud/translations/no.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Estimert klippet areal i dag" }, + "estimated_daily_progress": { + "name": "Estimert daglig fremgang" + }, "lawn_area": { "name": "Plenareal" }, diff --git a/custom_components/worx_vision_cloud/translations/pl.json b/custom_components/worx_vision_cloud/translations/pl.json index 6681008..3d14644 100644 --- a/custom_components/worx_vision_cloud/translations/pl.json +++ b/custom_components/worx_vision_cloud/translations/pl.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Szacowana powierzchnia skoszona dziś" }, + "estimated_daily_progress": { + "name": "Szacowany postęp dzienny" + }, "lawn_area": { "name": "Powierzchnia trawnika" }, diff --git a/custom_components/worx_vision_cloud/translations/sv.json b/custom_components/worx_vision_cloud/translations/sv.json index e1a7500..77cdb20 100644 --- a/custom_components/worx_vision_cloud/translations/sv.json +++ b/custom_components/worx_vision_cloud/translations/sv.json @@ -120,6 +120,9 @@ "estimated_area_mowed_today": { "name": "Uppskattad klippt yta idag" }, + "estimated_daily_progress": { + "name": "Uppskattat dagligt förlopp" + }, "lawn_area": { "name": "Gräsmattans yta" }, From e9f3db80e931e4640a44feff575d29eb322540da Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:48:39 +0200 Subject: [PATCH 20/21] Throttle Last update sensor to once per 24h (was every push) The Last update timestamp changes on every MQTT push (as often as every ~20 seconds), which made Home Assistant's logbook narrate a 'changed' entry that often for essentially no user value. Replace the generic STANDARD_SENSORS entry with a dedicated WorxLastUpdateSensor that only accepts a new value once per LAST_UPDATE_REPORT_INTERVAL (24h) and holds the previous one otherwise, restoring the accepted value/timestamp across restarts via RestoreSensor. The logbook now only sees one real change per day for this entity, while last_update_age (minutes since last update) is unaffected and still reflects live data. --- custom_components/worx_vision_cloud/sensor.py | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index edc0dc0..ab8c9f9 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -3,7 +3,7 @@ from asyncio import Task from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any, Callable from homeassistant.components.sensor import ( @@ -911,14 +911,6 @@ def _rtk_address_attributes( icon="mdi:axis-z-rotate-clockwise", value_fn=lambda d: _orientation(d, "yaw"), ), - WorxSensorDescription( - key="last_update", - translation_key="last_update", - device_class=SensorDeviceClass.TIMESTAMP, - icon="mdi:clock-check", - entity_category=EntityCategory.DIAGNOSTIC, - value_fn=_last_update, - ), WorxSensorDescription( key="last_update_age", translation_key="last_update_age", @@ -952,6 +944,7 @@ async def async_setup_entry( entities.append(WorxVisionAddressSensor(coordinator, entry, serial_number)) entities.append(WorxScheduleSensor(coordinator, entry, serial_number)) entities.append(WorxNextScheduleSensor(coordinator, entry, serial_number)) + entities.append(WorxLastUpdateSensor(coordinator, entry, serial_number)) entities.append(WorxAreaMowedTodaySensor(coordinator, entry, serial_number)) entities.append(WorxDailyProgressSensor(coordinator, entry, serial_number)) entities.append(WorxRemainingProgressSensor(coordinator, entry, serial_number)) @@ -1037,6 +1030,62 @@ def native_value(self) -> datetime | None: return next_schedule_start(self.device, dt_util.now()) +LAST_UPDATE_REPORT_INTERVAL = timedelta(hours=24) + + +class WorxLastUpdateSensor(WorxVisionEntity, RestoreSensor): + """Timestamp of the last data received from the mower. + + The underlying value changes on every push (as often as every ~20 + seconds), which would make Home Assistant's logbook narrate a "changed" + entry that often. Since this sensor is meant as an occasional heartbeat + check rather than a live clock, it only accepts a new value once per + LAST_UPDATE_REPORT_INTERVAL and otherwise keeps reporting the previous + one, so the logbook only sees one real change per interval. + """ + + _attr_translation_key = "last_update" + _attr_icon = "mdi:clock-check" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator, entry, serial_number: str) -> None: + """Initialize the last update sensor.""" + super().__init__(coordinator, entry, serial_number, "last_update") + self._reported_value: datetime | None = None + self._reported_at: datetime | None = None + + async def async_added_to_hass(self) -> None: + """Restore the last reported value and when it was accepted.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is None: + return + self._reported_value = _as_datetime(last_state.state) + self._reported_at = _as_datetime(last_state.attributes.get("reported_at")) + + @property + def native_value(self) -> datetime | None: + """Return the last-reported update time, refreshed at most once a day.""" + current = _last_update(self.device) + now = dt_util.utcnow() + if ( + self._reported_value is None + or self._reported_at is None + or now - self._reported_at >= LAST_UPDATE_REPORT_INTERVAL + ): + self._reported_value = current + self._reported_at = now + return self._reported_value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Persist when the reported value was accepted, for restarts.""" + return { + "reported_at": self._reported_at.isoformat() if self._reported_at else None, + } + + class WorxScheduleSensor(WorxVisionEntity, SensorEntity): """Compact weekly schedule summary, localized to the UI language.""" From b81b5e18cf932e26fc3382c02b3692af071b711f Mon Sep 17 00:00:00 2001 From: ADNPolymerase <111017981+ADNPolymerase@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:58:38 +0200 Subject: [PATCH 21/21] Track today's mowing time locally instead of Worx's work-time stats Confirmed live on a real Vision mower: Worx's work-time statistics (device.statistics / product-item) can stay frozen for 20+ minutes of continuous mowing despite the periodic forced refresh, so basing the estimated area/progress sensors on that figure inherited the same staleness problem they were meant to work around. Replace _WorxDailyRuntimeBase with _WorxDailyMowingTimeBase, which tracks wall-clock time spent in the mowing/starting status ourselves (accumulated seconds + an in-progress streak start time, both persisted via RestoreSensor), independent of whether Worx reports fresh work-time data. Every coordinator update (MQTT push, ~every 20s) recomputes this, so the estimate now genuinely moves during active mowing. Move MOWING_STATUS_IDS/STARTING_STATUS_IDS to helpers.py as the single source of truth, imported by both lawn_mower.py and sensor.py, so the new accumulator always agrees with what the lawn_mower entity shows. --- .../worx_vision_cloud/helpers.py | 6 + .../worx_vision_cloud/lawn_mower.py | 10 +- custom_components/worx_vision_cloud/sensor.py | 127 ++++++++++++------ 3 files changed, 96 insertions(+), 47 deletions(-) diff --git a/custom_components/worx_vision_cloud/helpers.py b/custom_components/worx_vision_cloud/helpers.py index b333638..e374628 100644 --- a/custom_components/worx_vision_cloud/helpers.py +++ b/custom_components/worx_vision_cloud/helpers.py @@ -10,6 +10,12 @@ from homeassistant.util import slugify +# Shared with lawn_mower.py and sensor.py so both agree on what "mowing" means +# (used e.g. to track today's actual mowing time independent of Worx's own, +# sometimes-stale work-time statistics). +MOWING_STATUS_IDS = {7, 8, 12, 32, 110, 111} +STARTING_STATUS_IDS = {2, 3, 33, 103} + RAW_SOURCE_ATTRS = ( "raw_dat", "raw_cfg", diff --git a/custom_components/worx_vision_cloud/lawn_mower.py b/custom_components/worx_vision_cloud/lawn_mower.py index ef98cf9..1a4e2f2 100644 --- a/custom_components/worx_vision_cloud/lawn_mower.py +++ b/custom_components/worx_vision_cloud/lawn_mower.py @@ -14,13 +14,17 @@ from .const import DOMAIN from .entity import WorxVisionEntity -from .helpers import get_dict_value, rtk_at_station, rtk_distance_to_station_m +from .helpers import ( + MOWING_STATUS_IDS, + STARTING_STATUS_IDS, + get_dict_value, + rtk_at_station, + rtk_distance_to_station_m, +) _LOGGER = logging.getLogger(__name__) -MOWING_STATUS_IDS = {7, 8, 12, 32, 110, 111} RETURNING_STATUS_IDS = {4, 5, 6, 30, 104} -STARTING_STATUS_IDS = {2, 3, 33, 103} PAUSED_STATUS_IDS = {34} DOCKED_STATUS_IDS = {1} ERROR_STATUS_IDS = {9, 10, 13} diff --git a/custom_components/worx_vision_cloud/sensor.py b/custom_components/worx_vision_cloud/sensor.py index ab8c9f9..26da02a 100644 --- a/custom_components/worx_vision_cloud/sensor.py +++ b/custom_components/worx_vision_cloud/sensor.py @@ -38,6 +38,8 @@ from .entity import WorxVisionEntity from .helpers import ( MAX_STRING_STATE_LENGTH, + MOWING_STATUS_IDS, + STARTING_STATUS_IDS, get_dict_value, next_schedule_start, raw_entity_path_map, @@ -1236,57 +1238,96 @@ def native_value(self) -> float | None: return round(max(0, 100 - progress), 1) -class _WorxDailyRuntimeBase(WorxVisionEntity, RestoreSensor): - """Base for sensors derived from mower work time since local midnight. +def _is_mowing_now(device) -> bool: + """Return whether the mower is currently mowing or starting to mow. - Unlike area_mowed (REST-only, can go stale for hours), work time is - live-pushed via MQTT statistics, so a delta-from-midnight baseline here - tracks today's runtime in near real time. + Mirrors lawn_mower.py's own definition of the MOWING activity, so this + always agrees with what the lawn_mower entity itself shows. + """ + status_id = get_dict_value(getattr(device, "status", {}), "id", -1) + return status_id in MOWING_STATUS_IDS or status_id in STARTING_STATUS_IDS + + +class _WorxDailyMowingTimeBase(WorxVisionEntity, RestoreSensor): + """Base for sensors derived from actual mowing time since local midnight. + + Worx's own work-time statistics (device.statistics / product-item) are + only included in some MQTT payloads and can go stale for hours during + active mowing (confirmed live: unchanged for 20+ minutes of continuous + mowing despite periodic forced refreshes). Instead, this tracks wall-clock + time spent in the mowing/starting status ourselves, from every coordinator + update, independent of whether Worx reports fresh statistics. """ _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator, entry, serial_number: str, key: str) -> None: - """Initialize the daily runtime base sensor.""" + """Initialize the daily mowing-time base sensor.""" super().__init__(coordinator, entry, serial_number, key) - self._baseline_total: float | None = None + self._accumulated_seconds: float = 0.0 + self._streak_started_at: datetime | None = None self._baseline_date: str | None = None async def async_added_to_hass(self) -> None: - """Restore the saved daily baseline.""" + """Restore the saved daily accumulator.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() if last_state is None: return try: - self._baseline_total = float(last_state.attributes["baseline_total"]) + self._accumulated_seconds = float( + last_state.attributes["accumulated_seconds"] + ) except (KeyError, TypeError, ValueError): - self._baseline_total = None + self._accumulated_seconds = 0.0 + self._streak_started_at = _as_datetime( + last_state.attributes.get("streak_started_at") + ) self._baseline_date = last_state.attributes.get("baseline_date") - def _today_runtime_minutes(self) -> float | None: - """Return mower work time since local midnight (minutes).""" - total = _work_time_total_minutes(self.device) - if total is None: - return None + def _today_mowing_minutes(self) -> float: + """Return actual mowing time since local midnight (minutes).""" + now = dt_util.utcnow() today = dt_util.now().date().isoformat() - if ( - self._baseline_total is None - or self._baseline_date != today - or total < self._baseline_total - ): - self._baseline_total = total + mowing_now = _is_mowing_now(self.device) + + if self._baseline_date != today: + # New day: drop yesterday's accumulator. A streak already running + # across midnight restarts from now rather than trying to split it. + self._accumulated_seconds = 0.0 self._baseline_date = today - return round(max(0.0, total - self._baseline_total), 2) + self._streak_started_at = now if mowing_now else None + if mowing_now and self._streak_started_at is None: + self._streak_started_at = now + elif not mowing_now and self._streak_started_at is not None: + self._accumulated_seconds += (now - self._streak_started_at).total_seconds() + self._streak_started_at = None -class WorxEstimatedAreaTodaySensor(_WorxDailyRuntimeBase): - """Estimated area mowed today, from today's runtime and average efficiency. + total_seconds = self._accumulated_seconds + if mowing_now and self._streak_started_at is not None: + total_seconds += (now - self._streak_started_at).total_seconds() + return round(max(0.0, total_seconds) / 60, 2) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Persist the accumulator so it survives restarts.""" + return { + "accumulated_seconds": self._accumulated_seconds, + "streak_started_at": self._streak_started_at.isoformat() + if self._streak_started_at + else None, + "baseline_date": self._baseline_date, + } + + +class WorxEstimatedAreaTodaySensor(_WorxDailyMowingTimeBase): + """Estimated area mowed today, from today's mowing time and average efficiency. area_mowed only refreshes when Worx's REST product-item endpoint reports a new figure, which can lag for hours during active mowing. This sensor - estimates today's coverage instead as today's work time (refreshed on the - coordinator's periodic device-update cadence, not just at session end) + estimates today's coverage instead as time actually spent mowing today + (tracked locally, independent of Worx's own statistics reporting) multiplied by the mower's average mowing efficiency (m2/h, itself REST-sourced but changes slowly), so it moves during the day even when Total/Today area mowed are stuck waiting for Worx to recompute the real @@ -1305,29 +1346,28 @@ def __init__(self, coordinator, entry, serial_number: str) -> None: @property def native_value(self) -> float | None: """Return today's estimated mowed area.""" - runtime_minutes = self._today_runtime_minutes() + mowing_minutes = self._today_mowing_minutes() efficiency = _mowing_efficiency(self.device) - if runtime_minutes is None or efficiency is None: + if efficiency is None: return None - return round(runtime_minutes / 60 * efficiency, 2) + return round(mowing_minutes / 60 * efficiency, 2) @property def extra_state_attributes(self) -> dict[str, Any]: - """Return the baseline plus the figures used for the estimate.""" + """Return the accumulator plus the figures used for the estimate.""" return { - "baseline_total": self._baseline_total, - "baseline_date": self._baseline_date, - "runtime_today_minutes": self._today_runtime_minutes(), + **super().extra_state_attributes, + "mowing_minutes_today": self._today_mowing_minutes(), "mowing_efficiency": _mowing_efficiency(self.device), } -class WorxEstimatedDailyProgressSensor(_WorxDailyRuntimeBase): +class WorxEstimatedDailyProgressSensor(_WorxDailyMowingTimeBase): """Estimated percentage of the lawn mowed today. - Same estimate as WorxEstimatedAreaTodaySensor (today's runtime x average - efficiency), expressed as a percentage of the known lawn area, so it - moves during the day even when the REST-based daily progress is stuck. + Same estimate as WorxEstimatedAreaTodaySensor (today's mowing time x + average efficiency), expressed as a percentage of the known lawn area, so + it moves during the day even when the REST-based daily progress is stuck. """ _attr_translation_key = "estimated_daily_progress" @@ -1341,21 +1381,20 @@ def __init__(self, coordinator, entry, serial_number: str) -> None: @property def native_value(self) -> float | None: """Return today's estimated progress in percent.""" - runtime_minutes = self._today_runtime_minutes() + mowing_minutes = self._today_mowing_minutes() efficiency = _mowing_efficiency(self.device) lawn_area = _lawn_area(self.device) - if runtime_minutes is None or efficiency is None or lawn_area in (None, 0): + if efficiency is None or lawn_area in (None, 0): return None - estimated_area = runtime_minutes / 60 * efficiency + estimated_area = mowing_minutes / 60 * efficiency return round(max(0, min(100, estimated_area / lawn_area * 100)), 1) @property def extra_state_attributes(self) -> dict[str, Any]: - """Return the baseline plus the figures used for the estimate.""" + """Return the accumulator plus the figures used for the estimate.""" return { - "baseline_total": self._baseline_total, - "baseline_date": self._baseline_date, - "runtime_today_minutes": self._today_runtime_minutes(), + **super().extra_state_attributes, + "mowing_minutes_today": self._today_mowing_minutes(), "mowing_efficiency": _mowing_efficiency(self.device), "lawn_area": _lawn_area(self.device), }