Add support for GOAT O800 RTK (9bts2s)#1466
Conversation
|
"Here is the raw device information from my Home Assistant logs for the Goat O800 RTK. The device class is definitely 9bts2s. I see that the latest CI build failed—please let me know if I can provide more data to help fix the integration!" { |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #1466 +/- ##
==========================================
+ Coverage 94.79% 94.82% +0.03%
==========================================
Files 152 153 +1
Lines 5974 6010 +36
Branches 350 350
==========================================
+ Hits 5663 5699 +36
Misses 249 249
Partials 62 62 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
So, the GOAT is now being found by the integration. Data such as battery status etc. are also perfectly correct. However, it cannot be controlled. The log shows the following error: Ps: lawn_mower.dock works out of the box |
I am also getting this message. I am a little confused on the current situation of this issue. |
|
Maybe this helps? Ecovacs Goat 800 RTK - Working command structureProblem: Official integration sends {
"name": "clean",
"td": "c",
"args": {
"action": "start",
"content": {
"subContent": {"type": "auto"}
}
}
}
MQTT topics observed:
State updates: onBattery, onChargeState, onCleanInfo, onChargeInfo, onStats
Charge command: Charge() works for both stop and dock
Working AppDaemon implementation:
[goat_mower.py](https://github.com/user-attachments/files/26648824/goat_mower.py)
|
|
Hey @mnistony, thanks for the great investigation work! Unfortunately the goat_mower.py attachment link is returning a 404. Could you re-upload it? Would love to try it as a workaround until this PR gets merged. Thanks! |
|
Hi,
I meanwhile have this and it works ok. I have to admit that I had help from the AI 😉
There are some entities which I can use in my dashboard:
sensor.goat_akku
input_number.goat_letzter_maehzyklus_dauer
input_datetime.goat_maehzyklus_start
sensor.goat_gemahte_flache
input_number.goat_maehzeit_gesamt
script.goat_start
script.goat_stop
script.goat_dock
If you are interested in the helper definitions or the scripts, I can send them too.
Cheers,
Stony
Von: allgrinder ***@***.***>
Gesendet: Mittwoch, 6. Mai 2026 11:43
An: DeebotUniverse/client.py ***@***.***>
Cc: mnistony ***@***.***>; Mention ***@***.***>
Betreff: Re: [DeebotUniverse/client.py] Add support for GOAT O800 RTK (9bts2s) (PR #1466)
<https://avatars.githubusercontent.com/u/75566736?s=20&v=4> allgrinder left a comment (DeebotUniverse/client.py#1466) <#1466 (comment)>
Hey @mnistony <https://github.com/mnistony> , thanks for the great investigation work! Unfortunately the goat_mower.py attachment link is returning a 404. Could you re-upload it? Would love to try it as a workaround until this PR gets merged. Thanks!
—
Reply to this email directly, view it on GitHub <#1466 (comment)> , or unsubscribe <https://github.com/notifications/unsubscribe-auth/BGGS6ZPJXSF3SOGF4BGCAUT4ZMCJLAVCNFSM6AAAAACWLXTTMSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DGOBWHAZDAOBVGA> .
Triage notifications on the go with GitHub Mobile for iOS <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675> or Android <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub> .
You are receiving this because you were mentioned. <https://github.com/notifications/beacon/BGGS6ZL3OMOSNX33YHTAEAT4ZMCJLBFCNFSM6AAAAACWLXTTMSWGG33NNVSW45C7OR4XAZNMJFZXG5LFINXW23LFNZ2KUY3PNVWWK3TUL5UWJTYAAAAACBLZSLZKM4TFMFZW63VHNVSW45DJN5XA.gif> Message ID: ***@***.*** ***@***.***> >
|
|
Hi allgrinder,
here is a (in English) commented version of the script:
Cheers,
Stony
Von: allgrinder ***@***.***>
Gesendet: Mittwoch, 6. Mai 2026 11:43
An: DeebotUniverse/client.py ***@***.***>
Cc: mnistony ***@***.***>; Mention ***@***.***>
Betreff: Re: [DeebotUniverse/client.py] Add support for GOAT O800 RTK (9bts2s) (PR #1466)
<https://avatars.githubusercontent.com/u/75566736?s=20&v=4> allgrinder left a comment (DeebotUniverse/client.py#1466) <#1466 (comment)>
Hey @mnistony <https://github.com/mnistony> , thanks for the great investigation work! Unfortunately the goat_mower.py attachment link is returning a 404. Could you re-upload it? Would love to try it as a workaround until this PR gets merged. Thanks!
—
Reply to this email directly, view it on GitHub <#1466 (comment)> , or unsubscribe <https://github.com/notifications/unsubscribe-auth/BGGS6ZPJXSF3SOGF4BGCAUT4ZMCJLAVCNFSM6AAAAACWLXTTMSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DGOBWHAZDAOBVGA> .
Triage notifications on the go with GitHub Mobile for iOS <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675> or Android <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub> .
You are receiving this because you were mentioned. <https://github.com/notifications/beacon/BGGS6ZL3OMOSNX33YHTAEAT4ZMCJLBFCNFSM6AAAAACWLXTTMSWGG33NNVSW45C7OR4XAZNMJFZXG5LFINXW23LFNZ2KUY3PNVWWK3TUL5UWJTYAAAAACBLZSLZKM4TFMFZW63VHNVSW45DJN5XA.gif> Message ID: ***@***.*** ***@***.***> >
|
|
I noticed my mail client may not have sent the Python script. Here it is again, renamed to .txt
Von: Bernd "Stony" Falkenstein ***@***.***>
Gesendet: Dienstag, 12. Mai 2026 12:39
An: 'DeebotUniverse/client.py' ***@***.***>
Betreff: AW: [DeebotUniverse/client.py] Add support for GOAT O800 RTK (9bts2s) (PR #1466)
Hi allgrinder,
here is a (in English) commented version of the script:
Cheers,
Stony
Von: allgrinder ***@***.*** ***@***.***> >
Gesendet: Mittwoch, 6. Mai 2026 11:43
An: DeebotUniverse/client.py ***@***.*** ***@***.***> >
Cc: mnistony ***@***.*** ***@***.***> >; Mention ***@***.*** ***@***.***> >
Betreff: Re: [DeebotUniverse/client.py] Add support for GOAT O800 RTK (9bts2s) (PR #1466)
<https://avatars.githubusercontent.com/u/75566736?s=20&v=4> allgrinder left a comment (DeebotUniverse/client.py#1466) <#1466 (comment)>
Hey @mnistony <https://github.com/mnistony> , thanks for the great investigation work! Unfortunately the goat_mower.py attachment link is returning a 404. Could you re-upload it? Would love to try it as a workaround until this PR gets merged. Thanks!
—
Reply to this email directly, view it on GitHub <#1466 (comment)> , or unsubscribe <https://github.com/notifications/unsubscribe-auth/BGGS6ZPJXSF3SOGF4BGCAUT4ZMCJLAVCNFSM6AAAAACWLXTTMSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DGOBWHAZDAOBVGA> .
Triage notifications on the go with GitHub Mobile for iOS <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675> or Android <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub> .
You are receiving this because you were mentioned. <https://github.com/notifications/beacon/BGGS6ZL3OMOSNX33YHTAEAT4ZMCJLBFCNFSM6AAAAACWLXTTMSWGG33NNVSW45C7OR4XAZNMJFZXG5LFINXW23LFNZ2KUY3PNVWWK3TUL5UWJTYAAAAACBLZSLZKM4TFMFZW63VHNVSW45DJN5XA.gif> Message ID: ***@***.*** ***@***.***> >
"""
================================================================================
goat_mower.py – AppDaemon App for the GOAT O800 RTK Robotic Lawnmower
================================================================================
Purpose:
Connects the GOAT O800 RTK robotic lawnmower to Home Assistant via the
Ecovacs/Deebot cloud. Since the GOAT uses the same cloud stack as Deebot
vacuum robots, the unofficial deebot_client library is used for
authentication and MQTT communication.
Requirements:
- AppDaemon (e.g. as a HA add-on)
- Python packages: deebot_client, aiohttp (pip install deebot-client aiohttp)
- An MQTT broker integrated into Home Assistant
- An Ecovacs/GOAT account with a registered device
Configuration (apps.yaml):
goat_mower:
module: goat_mower
class: GoatMower
username: ***@***.***" # Ecovacs login
password: "secret_password" # Ecovacs password (plain text – internally converted to MD5 hash)
country: "DE" # Country code (ISO 3166-1 alpha-2), optional, default: DE
…--------------------------------------------------------------------------------
Published MQTT Topics (written by this script):
--------------------------------------------------------------------------------
goat/state
Current device status as JSON:
{
"state": "docked" | "cleaning" | "returning" | "paused" | "idle",
"battery_level": 0–100,
"charging": true | false
}
Retain: yes
goat/lifespan/blade
Blade wear status as JSON:
{
"percent": 0–100, # remaining blade life in percent
"hours_used": 12.5, # hours used so far
"hours_max": 100 # configured maximum (BLADE_MAX_HOURS)
}
Retain: yes
goat/lifespan/blade_seconds
Total mowing runtime of the current blade in seconds (integer as string).
Also mirrored in sensor.goat_klingen_sekunden (see below).
Retain: yes
goat/stats (only on onStats events from the Deebot API, rare on GOAT)
{"mowedArea": ..., "area": ..., "time": ...}
Retain: yes
--------------------------------------------------------------------------------
Required Home Assistant Entities:
--------------------------------------------------------------------------------
sensor.goat_klingen_sekunden
Type: MQTT sensor (or template sensor)
Source: goat/lifespan/blade_seconds
Purpose: Persistent storage of the total blade runtime –
this value is re-read on app restart so the counter
is not reset to zero.
Example configuration (configuration.yaml):
mqtt:
sensor:
- name: "GOAT Blade Seconds"
unique_id: goat_klingen_sekunden
state_topic: "goat/lifespan/blade_seconds"
input_datetime.goat_letzter_klingenwechsel
Type: input_datetime (date only)
Purpose: Set to today's date when the goat_reset_blade event fires.
Display on dashboard e.g. as "Last blade change: 2025-04-01"
--------------------------------------------------------------------------------
Fired Home Assistant Events:
--------------------------------------------------------------------------------
goat_maehsession_ende
Fired at the end of a complete mowing session (trigger=workComplete).
Event data:
session_seconds (int) – session duration in seconds
mowed_area (float) – mowed area in m²
blade_seconds_total (int) – total blade runtime in seconds
Use: e.g. automation to update daily/weekly statistics
--------------------------------------------------------------------------------
Listened Home Assistant Events (commands):
--------------------------------------------------------------------------------
goat_start → Start mowing
goat_stop → Stop mowing (robot returns to dock)
goat_dock → Send robot to dock (identical to goat_stop)
goat_reset_blade → Reset blade counter (after a blade change)
Sets blade_seconds to 0 and writes today's date
to input_datetime.goat_letzter_klingenwechsel
Trigger e.g. via automation or script:
service: event.fire
event_type: goat_start
--------------------------------------------------------------------------------
Internal MQTT Topics (received from the device, processed here):
--------------------------------------------------------------------------------
onFwBuryPoint-bd_reedvoltage
Reed contact voltage in mV.
REED_DOCKED_THRESHOLD (5000 mV) → robot is on the charging station (docked)
≤ REED_DOCKED_THRESHOLD → robot is mowing (cleaning)
onFwBuryPoint-bd_batteryinfo
Battery level (batteryLevel, 0–100).
onFwBuryPoint-bd_task-mow-auto-stop
Signals the end of a mowing session.
Fields: time (seconds), trigger (e.g. "workComplete"), mowedArea (m²)
Only when trigger="workComplete" is the blade counter incremented and
the goat_maehsession_ende event fired.
onBattery, onChargeState, onCleanInfo, onChargeInfo, onStats
Classic Deebot API topics – rarely or never seen on the GOAT,
but handled for maximum compatibility.
--------------------------------------------------------------------------------
Key Constants (adjustable at the top of the file):
--------------------------------------------------------------------------------
BLADE_MAX_HOURS (default: 100)
Recommended maximum blade runtime in hours.
Determines the percent calculation in goat/lifespan/blade.
REED_DOCKED_THRESHOLD (default: 5000 mV)
Voltage threshold for the reed contact sensor.
While charging: ~7000–19500 mV, while mowing: ~177–878 mV.
The value sits safely in the middle – no adjustment normally needed.
================================================================================
"""
import appdaemon.plugins.hass.hassapi as hass
from aiohttp import ClientSession
from deebot_client.authentication import Authenticator, create_rest_config
from deebot_client.api_client import ApiClient
from deebot_client.mqtt_client import MqttClient, SubscriberInfo, create_mqtt_config
from deebot_client.event_bus import EventBus
from deebot_client.commands.json.clean import CleanV2
from deebot_client.commands.json.charge import Charge
from deebot_client.models import CleanAction
from hashlib import md5
from datetime import date
import uuid
import json
import ssl
from typing import Any
# Blade change recommended after this many hours of runtime.
# When the blade reaches this value, percent in goat/lifespan/blade will be 0.
BLADE_MAX_HOURS = 100
# Reed contact voltage threshold in millivolts:
# Voltage > REED_DOCKED_THRESHOLD → robot is on the charging station
# Voltage <= REED_DOCKED_THRESHOLD → robot is out mowing
# Typical values: charging ~7000–19500 mV, mowing ~177–878 mV
REED_DOCKED_THRESHOLD = 5000
class GoatStart(CleanV2):
"""
Customised clean command for the GOAT O800.
The GOAT requires an additional subContent field with type="auto" in the
start command, which the standard CleanV2 class from deebot_client does
not include. This subclass adds exactly that field.
"""
NAME = "clean"
@Property
def td(self) -> str:
# Internal type identifier for the Deebot protocol
return "c"
def _get_args(self, action: CleanAction) -> dict[str, Any]:
args = super()._get_args(action)
if action == CleanAction.START:
# Without this subContent the GOAT ignores the start command
args["content"]["subContent"] = {"type": "auto"}
return args
class GoatMower(hass.Hass):
"""
Main AppDaemon app for the GOAT O800 RTK robotic lawnmower.
Startup sequence:
1. Authenticate with the Ecovacs cloud (REST API)
2. Fetch device list and identify the GOAT device
3. Establish MQTT connection to the Ecovacs broker
4. Process incoming MQTT messages and mirror status to HA/MQTT
5. Listen for HA events to handle control commands
"""
async def initialize(self):
"""
Called by AppDaemon on startup.
Sets up the cloud and MQTT connection and registers all listeners.
"""
# Read credentials from apps.yaml
email = self.args["username"]
password = self.args["password"]
country = self.args.get("country", "DE").upper()
# Base MQTT topic for all messages published by this script
self.mqtt_topic = "goat"
# Ecovacs requires the MD5 hash of the password (not the plain-text password)
password_hash = md5(password.encode()).hexdigest()
# Random device ID – identifies this app instance to the cloud server
self.device_id = uuid.uuid4().hex
# Internal state store (mirrored to MQTT via goat/state)
self._state = {"state": "docked", "battery_level": 100, "charging": False}
# Total runtime of the current blade in seconds (reloaded from HA on restart)
self._blade_seconds = 0
try:
# HTTP session for Ecovacs REST calls
self._session = ClientSession()
rest_config = create_rest_config(
self._session,
device_id=self.device_id,
alpha_2_country=country,
)
# Authenticate and fetch the device catalogue
self._auth = Authenticator(
config=rest_config,
account_id=email,
password_hash=password_hash,
)
self.api = ApiClient(authenticator=self._auth)
devices = await self.api.get_devices()
except Exception:
# Cloud connection failed – shut down cleanly
if hasattr(self, "_session") and not self._session.closed:
await self._session.close()
return
# Merge all device lists (MQTT, XMPP, not supported) and pick the
# first device whose name contains "GOAT"
all_devices = list(devices.mqtt) + list(devices.xmpp) + list(devices.not_supported)
self.device = None
for d in all_devices:
name = d.get("deviceName", d.get("name", "?")) if isinstance(d, dict) else getattr(d, "device_name", "?")
if "GOAT" in name.upper():
self.device = d
self.device_name = name
if not self.device:
# No GOAT device found in the Ecovacs account
return
# TLS without certificate verification – the Ecovacs broker does not
# have a publicly trusted certificate
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
# Determine the MQTT broker address from the device data
jmq_host = self.device.get("service", {}).get("jmq")
mqtt_cfg = create_mqtt_config(
device_id=self.device_id,
country=country,
override_mqtt_url=f"mqtts://{jmq_host}:8883" if jmq_host else None,
ssl_context=ssl_ctx,
)
self._mqtt = MqttClient(config=mqtt_cfg, authenticator=self._auth)
# ----------------------------------------------------------------
# Callback for incoming MQTT messages from the device
# ----------------------------------------------------------------
def on_message(topic: str, payload) -> None:
"""
Processes every incoming MQTT message from the GOAT.
The GOAT almost exclusively sends onFwBuryPoint messages
(proprietary data format). Classic Deebot topics such as
onBattery or onCleanInfo are rarely seen in practice but are
handled for completeness.
"""
try:
data = json.loads(payload) if isinstance(payload, (str, bytes)) else payload
except Exception:
data = str(payload)
changed = False # Flag: has the internal state actually changed?
body = data.get("body", {}) if isinstance(data, dict) else {}
d = body.get("data", {}) # data sub-field (used in classic Deebot topics)
# ----------------------------------------------------------------
# onFwBuryPoint – the only reliable data source on the GOAT.
# All relevant status information arrives via this channel.
# ----------------------------------------------------------------
if topic.startswith("onFwBuryPoint"):
if topic == "onFwBuryPoint-bd_reedvoltage":
# Reed contact voltage: indicates whether the robot is on
# the charging station (high voltage) or mowing (low voltage)
voltage = body.get("voltage")
if voltage is not None:
if voltage > REED_DOCKED_THRESHOLD:
if self._state["state"] != "docked":
self._state["state"] = "docked"
self._state["charging"] = True
changed = True
self.log(f"GOAT: Reed={voltage}mV → docked", level="INFO")
else:
if self._state["state"] != "cleaning":
self._state["state"] = "cleaning"
self._state["charging"] = False
changed = True
self.log(f"GOAT: Reed={voltage}mV → cleaning", level="INFO")
elif topic == "onFwBuryPoint-bd_batteryinfo":
# Simple battery level update; triggers no further actions
level = body.get("batteryLevel")
if level is not None and level != self._state["battery_level"]:
self._state["battery_level"] = level
changed = True
elif topic == "onFwBuryPoint-bd_task-mow-auto-stop":
# This topic signals the end of a mowing session.
# It contains the duration, trigger reason and mowed area.
session_seconds = body.get("time", 0)
trigger_type = body.get("trigger", "")
mowed_area = body.get("mowedArea", 0)
self.log(
f"GOAT: Mowing session ended, trigger={trigger_type}, "
f"duration={session_seconds:.0f}s, area={mowed_area:.1f}m², body={body}",
level="INFO"
)
if trigger_type == "workComplete":
# Genuine end of day: increment blade counter and
# fire event for automations
if session_seconds > 0:
self._blade_seconds += session_seconds
self._publish_blade_wear()
# Event goat_maehsession_ende is available in HA for
# automations (see file header for event data fields)
self.fire_event("goat_maehsession_ende",
session_seconds=int(session_seconds),
mowed_area=mowed_area,
blade_seconds_total=int(self._blade_seconds))
self.log(f"GOAT: workComplete → event goat_maehsession_ende fired", level="INFO")
else:
# Charging pause or other stop reason → log only, no action
self.log(f"GOAT: trigger={trigger_type} → no action (charging pause?)", level="INFO")
else:
# Unknown onFwBuryPoint sub-topic – log at DEBUG level only
self.log(f"GOAT FwBuryPoint: topic={topic} body={body}", level="DEBUG")
# ----------------------------------------------------------------
# Classic Deebot topics (rarely or never seen on the GOAT,
# but handled for compatibility)
# ----------------------------------------------------------------
elif topic == "onBattery":
# Battery level update via classic Deebot channel
val = d.get("value")
if val is not None and val != self._state["battery_level"]:
self._state["battery_level"] = val
changed = True
elif topic == "onChargeState":
# Charging state change via classic Deebot channel
is_charging = d.get("isCharging")
if is_charging == 1 and self._state["state"] != "docked":
self._state["state"] = "docked"
self._state["charging"] = True
changed = True
elif is_charging == 0 and self._state["charging"]:
self._state["charging"] = False
changed = True
elif topic == "onCleanInfo":
# Robot motion state via classic Deebot channel
motion = d.get("cleanState", {}).get("motionState")
if motion == "pause" and self._state["state"] != "paused":
self._state["state"] = "paused"
changed = True
elif motion in ["work", "working", "clean"] and self._state["state"] != "cleaning":
self._state["state"] = "cleaning"
self._state["charging"] = False
changed = True
elif motion in ["finish", "idle", "standby"] and self._state["state"] not in ["docked", "returning"]:
self._state["state"] = "idle"
changed = True
elif topic == "onChargeInfo":
# Return to charging station via classic Deebot channel
charge_state = d.get("state")
if charge_state == "goCharging" and self._state["state"] != "returning":
self._state["state"] = "returning"
self._state["charging"] = False
changed = True
elif charge_state in ["docking", "idle"] and self._state["state"] != "docked":
self._state["state"] = "docked"
self._state["charging"] = True
changed = True
elif topic == "onStats":
# Statistics update (mowed area, total area, time)
# Published as JSON to goat/stats
mowed = d.get("mowedArea", 0)
area = d.get("area", 0)
mow_time = d.get("time", 0)
self.call_service("mqtt/publish",
topic=f"{self.mqtt_topic}/stats",
payload=json.dumps({"mowedArea": mowed, "area": area, "time": mow_time}),
retain=True)
# Only publish state when something actually changed
if changed:
self._publish_state()
# EventBus instance (required by deebot_client but not actively used)
self._event_bus = EventBus(
execute_command=lambda *a, **kw: None,
get_refresh_commands=lambda *a: [],
)
# Register subscriber – connects the on_message callback to the MQTT client
subscriber = SubscriberInfo(
device_info=self.device,
events=self._event_bus,
callback=on_message,
)
await self._mqtt.subscribe(subscriber)
# Register HA event listeners for control commands
self.listen_event(self._cmd_start, "goat_start") # Start mowing
self.listen_event(self._cmd_stop, "goat_stop") # Stop mowing
self.listen_event(self._cmd_dock, "goat_dock") # Send to dock
self.listen_event(self._cmd_reset_blade, "goat_reset_blade") # Reset blade counter
# Reload blade runtime from HA sensor (30 s delay to allow HA to finish booting)
self.run_in(self._load_blade_seconds, 30)
# Publish initial status immediately, then every 10 minutes
self.run_in(self._force_status_update, 10)
self.run_every(self._force_status_update, "now+600", 600)
# ----------------------------------------------------------------
# Internal helper methods
# ----------------------------------------------------------------
def _publish_state(self):
"""Publishes the current device state to goat/state (retain=True)."""
self.call_service("mqtt/publish",
topic=f"{self.mqtt_topic}/state",
payload=json.dumps(self._state),
retain=True)
def _publish_blade_wear(self):
"""
Calculates blade wear and publishes it to two topics:
- goat/lifespan/blade → JSON with percent, hours, maximum
- goat/lifespan/blade_seconds → total runtime in seconds (for HA sensor)
"""
max_seconds = BLADE_MAX_HOURS * 3600
used_pct = min(self._blade_seconds / max_seconds, 1.0)
remaining_pct = round((1.0 - used_pct) * 100) # remaining blade life in %
hours_used = round(self._blade_seconds / 3600, 1)
self.call_service("mqtt/publish",
topic=f"{self.mqtt_topic}/lifespan/blade",
payload=json.dumps({
"percent": remaining_pct,
"hours_used": hours_used,
"hours_max": BLADE_MAX_HOURS,
}),
retain=True)
# Raw seconds value for sensor.goat_klingen_sekunden in HA
self.call_service("mqtt/publish",
topic=f"{self.mqtt_topic}/lifespan/blade_seconds",
payload=str(int(self._blade_seconds)),
retain=True)
async def _load_blade_seconds(self, kwargs):
"""
Reads sensor.goat_klingen_sekunden from HA and restores the blade
counter after an app restart.
Without this restore the counter would start at 0 on every restart.
"""
try:
state = self.get_state("sensor.goat_klingen_sekunden")
if state and state not in ["unknown", "unavailable"]:
self._blade_seconds = float(state)
self._publish_blade_wear()
except Exception:
pass
async def _force_status_update(self, kwargs):
"""
Periodic forced republication of state and blade wear.
Ensures MQTT values are present again after a broker restart.
"""
if hasattr(self, "mqtt_topic"):
self._publish_state()
self._publish_blade_wear()
# ----------------------------------------------------------------
# Command handlers (triggered by HA events)
# ----------------------------------------------------------------
async def _send_command(self, cmd):
"""Sends a command to the GOAT via the Ecovacs cloud. Errors are silently ignored."""
try:
await cmd.execute(self._auth, self.device, self._event_bus)
except Exception:
pass
async def _cmd_start(self, event_name, data, kwargs):
"""Starts the mowing process. Triggered by HA event goat_start."""
self._state["charging"] = False
await self._send_command(GoatStart(CleanAction.START))
async def _cmd_stop(self, event_name, data, kwargs):
"""Stops mowing and sends the robot to the dock. Event: goat_stop."""
await self._send_command(Charge())
async def _cmd_dock(self, event_name, data, kwargs):
"""Sends the robot to the dock. Event: goat_dock (identical to goat_stop)."""
await self._send_command(Charge())
async def _cmd_reset_blade(self, event_name, data, kwargs):
"""
Resets the blade counter (after a blade change).
Triggered by HA event goat_reset_blade.
Actions:
- Sets _blade_seconds to 0
- Republishes goat/lifespan/blade* (100 % remaining)
- Sets input_datetime.goat_letzter_klingenwechsel to today's date
"""
self._blade_seconds = 0
self._publish_blade_wear()
self.call_service(
"input_datetime/set_datetime",
entity_id="input_datetime.goat_letzter_klingenwechsel",
date=date.today().isoformat(),
)
async def terminate(self):
"""
Called by AppDaemon on shutdown.
Closes the MQTT connection and HTTP session cleanly.
"""
if hasattr(self, "_mqtt"):
await self._mqtt.disconnect()
if hasattr(self, "_session") and not self._session.closed:
await self._session.close()
|
Adding support for GOAT O800 RTK (class: 9bts2s) by linking
to the existing GOAT G1 capabilities (5xu9h3.py).
Device info: