Files
2023-11-21 12:23:38 +01:00

700 lines
24 KiB
Python

"""Bosch Indego Mower integration."""
import asyncio
import logging
from datetime import datetime, timedelta
import homeassistant.util.dt
import voluptuous as vol
from homeassistant.core import HomeAssistant, CoreState
from homeassistant.exceptions import HomeAssistantError
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_CONNECTIVITY,
DEVICE_CLASS_PROBLEM,
)
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_NAME,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_TIMESTAMP,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
STATE_ON,
STATE_UNKNOWN,
TEMP_CELSIUS,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import async_get_config_entry_implementation
from homeassistant.helpers.event import async_track_point_in_time
from pyIndego import IndegoAsyncClient
from .api import IndegoOAuth2Session
from .binary_sensor import IndegoBinarySensor
from .const import (
STATUS_UPDATE_FAILURE_DELAY_TIME,
BINARY_SENSOR_TYPE,
CONF_MOWER_SERIAL,
CONF_MOWER_NAME,
CONF_SERVICES_REGISTERED,
CONF_ATTR,
CONF_SEND_COMMAND,
CONF_SMARTMOWING,
DEFAULT_NAME_COMMANDS,
DOMAIN,
ENTITY_ALERT,
ENTITY_BATTERY,
ENTITY_LAST_COMPLETED,
ENTITY_LAWN_MOWED,
ENTITY_MOWER_STATE,
ENTITY_MOWER_STATE_DETAIL,
ENTITY_MOWING_MODE,
ENTITY_NEXT_MOW,
ENTITY_ONLINE,
ENTITY_RUNTIME,
ENTITY_UPDATE_AVAILABLE,
INDEGO_PLATFORMS,
SENSOR_TYPE,
SERVICE_NAME_COMMAND,
SERVICE_NAME_SMARTMOW,
)
from .sensor import IndegoSensor
_LOGGER = logging.getLogger(__name__)
SERVICE_SCHEMA_COMMAND = vol.Schema({
vol.Optional(CONF_MOWER_SERIAL): cv.string,
vol.Required(CONF_SEND_COMMAND): cv.string
})
SERVICE_SCHEMA_SMARTMOWING = vol.Schema({
vol.Optional(CONF_MOWER_SERIAL): cv.string,
vol.Required(CONF_SMARTMOWING): cv.string
})
def FUNC_ICON_MOWER_ALERT(state):
if state:
if int(state) > 0 or state == STATE_ON:
return "mdi:alert-outline"
return "mdi:check-circle-outline"
ENTITY_DEFINITIONS = {
ENTITY_ONLINE: {
CONF_TYPE: BINARY_SENSOR_TYPE,
CONF_NAME: "online",
CONF_ICON: "mdi:cloud-check",
CONF_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY,
CONF_ATTR: [],
},
ENTITY_UPDATE_AVAILABLE: {
CONF_TYPE: BINARY_SENSOR_TYPE,
CONF_NAME: "update available",
CONF_ICON: "mdi:download-outline",
CONF_DEVICE_CLASS: None,
CONF_ATTR: [],
},
ENTITY_ALERT: {
CONF_TYPE: BINARY_SENSOR_TYPE,
CONF_NAME: "alert",
CONF_ICON: FUNC_ICON_MOWER_ALERT,
CONF_DEVICE_CLASS: DEVICE_CLASS_PROBLEM,
CONF_ATTR: ["alerts_count"],
},
ENTITY_MOWER_STATE: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "mower state",
CONF_ICON: "mdi:robot-mower-outline",
CONF_DEVICE_CLASS: None,
CONF_UNIT_OF_MEASUREMENT: None,
CONF_ATTR: ["last_updated"],
},
ENTITY_MOWER_STATE_DETAIL: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "mower state detail",
CONF_ICON: "mdi:robot-mower-outline",
CONF_DEVICE_CLASS: None,
CONF_UNIT_OF_MEASUREMENT: None,
CONF_ATTR: [
"last_updated",
"state_number",
"state_description",
],
},
ENTITY_BATTERY: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "battery %",
CONF_ICON: "battery",
CONF_DEVICE_CLASS: DEVICE_CLASS_BATTERY,
CONF_UNIT_OF_MEASUREMENT: "%",
CONF_ATTR: [
"last_updated",
"voltage_V",
"discharge_Ah",
"cycles",
f"battery_temp_{TEMP_CELSIUS}",
f"ambient_temp_{TEMP_CELSIUS}",
],
},
ENTITY_LAWN_MOWED: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "lawn mowed",
CONF_ICON: "mdi:grass",
CONF_DEVICE_CLASS: None,
CONF_UNIT_OF_MEASUREMENT: "%",
CONF_ATTR: [
"last_updated",
"last_completed_mow",
"next_mow",
"last_session_operation_min",
"last_session_cut_min",
"last_session_charge_min",
],
},
ENTITY_LAST_COMPLETED: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "last completed",
CONF_ICON: "mdi:calendar-check",
CONF_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP,
CONF_UNIT_OF_MEASUREMENT: "ISO8601",
CONF_ATTR: [],
},
ENTITY_NEXT_MOW: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "next mow",
CONF_ICON: "mdi:calendar-clock",
CONF_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP,
CONF_UNIT_OF_MEASUREMENT: "ISO8601",
CONF_ATTR: [],
},
ENTITY_MOWING_MODE: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "mowing mode",
CONF_ICON: "mdi:alpha-m-circle-outline",
CONF_DEVICE_CLASS: None,
CONF_UNIT_OF_MEASUREMENT: None,
CONF_ATTR: [],
},
ENTITY_RUNTIME: {
CONF_TYPE: SENSOR_TYPE,
CONF_NAME: "mowtime total",
CONF_ICON: "mdi:information-outline",
CONF_DEVICE_CLASS: None,
CONF_UNIT_OF_MEASUREMENT: "h",
CONF_ATTR: [
"total_mowing_time_h",
"total_charging_time_h",
"total_operation_time_h",
],
},
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
hass.data.setdefault(DOMAIN, {})
entry_implementation = await async_get_config_entry_implementation(hass, entry)
oauth_session = IndegoOAuth2Session(hass, entry, entry_implementation)
indego_hub = hass.data[DOMAIN][entry.entry_id] = IndegoHub(
entry.data[CONF_MOWER_NAME],
oauth_session,
entry.data[CONF_MOWER_SERIAL],
hass,
)
async def load_platforms():
_LOGGER.debug("Loading platforms")
await asyncio.gather(
*(
hass.config_entries.async_forward_entry_setup(entry, platform)
for platform in INDEGO_PLATFORMS
)
)
try:
await indego_hub.update_generic_data_and_load_platforms(load_platforms)
except AttributeError as e:
_LOGGER.warning("Login unsuccessful: %s", e)
return False
def find_instance_for_mower_service_call(call):
mower_serial = call.data.get(CONF_MOWER_SERIAL, None)
if mower_serial is None:
# Return the first instance when params is missing for backwards compatibility.
return hass.data[DOMAIN][hass.data[DOMAIN][CONF_SERVICES_REGISTERED]]
for config_entry_id in hass.data[DOMAIN]:
if config_entry_id == CONF_SERVICES_REGISTERED:
continue
instance = hass.data[DOMAIN][config_entry_id]
if instance.serial == mower_serial:
return instance
raise HomeAssistantError("No mower instance found for serial '%s'" % mower_serial)
async def async_send_command(call):
"""Handle the mower command service call."""
instance = find_instance_for_mower_service_call(call)
command = call.data.get(CONF_SEND_COMMAND, DEFAULT_NAME_COMMANDS)
_LOGGER.debug("Indego.send_command service called, with command: %s", command)
await instance._indego_client.put_command(command)
await instance._update_state()
async def async_send_smartmowing(call):
"""Handle the smartmowing service call."""
instance = find_instance_for_mower_service_call(call)
enable = call.data.get(CONF_SMARTMOWING, DEFAULT_NAME_COMMANDS)
_LOGGER.debug("Indego.send_smartmowing service called, set to %s", enable)
await instance._indego_client.put_mow_mode(enable)
await instance._update_generic_data()
# In HASS we can have multiple Indego component instances as long as the mower serial is unique.
# So the mower services should only need to be registered for the first instance.
if CONF_SERVICES_REGISTERED not in hass.data[DOMAIN]:
_LOGGER.debug("Initializing mower service for config entry '%s'", entry.entry_id)
hass.services.async_register(
DOMAIN,
SERVICE_NAME_COMMAND,
async_send_command,
schema=SERVICE_SCHEMA_COMMAND
)
hass.services.async_register(
DOMAIN,
SERVICE_NAME_SMARTMOW,
async_send_smartmowing,
schema=SERVICE_SCHEMA_SMARTMOWING,
)
hass.data[DOMAIN][CONF_SERVICES_REGISTERED] = entry.entry_id
else:
_LOGGER.debug("Indego mower services already registered. Skipping for config entry '%s'", entry.entry_id)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, INDEGO_PLATFORMS)
if not unload_ok:
return False
if CONF_SERVICES_REGISTERED in hass.data[DOMAIN] and hass.data[DOMAIN][CONF_SERVICES_REGISTERED] == entry.entry_id:
del hass.data[DOMAIN][CONF_SERVICES_REGISTERED]
await hass.data[DOMAIN][entry.entry_id].async_shutdown()
del hass.data[DOMAIN][entry.entry_id]
return True
class IndegoHub:
"""Class for the IndegoHub, which controls the sensors and binary sensors."""
def __init__(self, name: str, session: IndegoOAuth2Session, serial: str, hass: HomeAssistant):
"""Initialize the IndegoHub.
Args:
name (str): the name of the mower for entities
session (IndegoOAuth2Session): the Bosch SingleKey ID OAuth session
serial (str): serial of the mower, is used for uniqueness
hass (HomeAssistant): HomeAssistant instance
"""
self._mower_name = name
self._serial = serial
self._hass = hass
self._unsub_refresh_state = None
self._refresh_state_task = None
self._refresh_10m_remover = None
self._refresh_24h_remover = None
self._shutdown = False
self._latest_alert = None
self.entities = {}
async def async_token_refresh() -> str:
await session.async_ensure_token_valid()
return session.token["access_token"]
self._indego_client = IndegoAsyncClient(
token=session.token["access_token"],
token_refresh_method=async_token_refresh,
serial=self._serial,
session=async_get_clientsession(hass),
raise_request_exceptions=True
)
def _create_entities(self, device_info):
"""Create sub-entities and add them to Hass."""
_LOGGER.debug("Creating entities")
for entity_key, entity in ENTITY_DEFINITIONS.items():
if entity[CONF_TYPE] == SENSOR_TYPE:
self.entities[entity_key] = IndegoSensor(
f"indego_{self._serial}_{entity_key}",
f"{self._mower_name} {entity[CONF_NAME]}",
entity[CONF_ICON],
entity[CONF_DEVICE_CLASS],
entity[CONF_UNIT_OF_MEASUREMENT],
entity[CONF_ATTR],
device_info
)
elif entity[CONF_TYPE] == BINARY_SENSOR_TYPE:
self.entities[entity_key] = IndegoBinarySensor(
f"indego_{self._serial}_{entity_key}",
f"{self._mower_name} {entity[CONF_NAME]}",
entity[CONF_ICON],
entity[CONF_DEVICE_CLASS],
entity[CONF_ATTR],
device_info
)
async def update_generic_data_and_load_platforms(self, load_platforms):
"""Update the generic mower data, so we can create the HA platforms for the Indego component."""
_LOGGER.debug("Getting generic data for device info.")
generic_data = await self._update_generic_data()
device_info = DeviceInfo(
identifiers={(DOMAIN, self._serial)},
serial_number=self._serial,
manufacturer="Bosch",
name=self._mower_name,
model=generic_data.bareToolnumber if generic_data else None,
sw_version=generic_data.alm_firmware_version if generic_data else None,
)
self._create_entities(device_info)
await load_platforms()
if self._hass.state == CoreState.running:
# HA has already been started (this probably an integration reload).
# Perform initial update right away...
self._hass.async_create_task(self._initial_update())
else:
# HA is still starting, delay the initial update...
self._hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED, self._initial_update
)
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_shutdown)
async def _initial_update(self, _=None):
"""Do the initial update and create all entities."""
_LOGGER.debug("Starting initial update.")
self._create_refresh_state_task()
await asyncio.gather(*[self.refresh_10m(), self.refresh_24h()])
try:
_LOGGER.debug("Refreshing initial operating data.")
await self._update_operating_data()
except Exception as e:
_LOGGER.info("Update operating data got an exception: %s", e)
async def async_shutdown(self, _=None):
"""Remove all future updates, cancel tasks and close the client."""
if self._shutdown:
return
_LOGGER.debug("Starting shutdown.")
self._shutdown = True
self._cancel_delayed_refresh_state()
if self._refresh_state_task:
self._refresh_state_task.cancel()
await self._refresh_state_task
self._refresh_state_task = None
if self._refresh_10m_remover:
self._refresh_10m_remover()
if self._refresh_24h_remover:
self._refresh_24h_remover()
await self._indego_client.close()
_LOGGER.debug("Shutdown finished.")
async def refresh_state(self):
"""Update the state, if necessary update operating data and recall itself."""
_LOGGER.debug("Refreshing state.")
self._cancel_delayed_refresh_state()
update_failed = False
try:
await self._update_state()
except Exception as exc:
update_failed = True
_LOGGER.warning("Mower state update failed, reason: %s", exc)
if self._shutdown:
return
if update_failed:
_LOGGER.debug("Delaying next status update with %i seconds due to previous failure...", STATUS_UPDATE_FAILURE_DELAY_TIME)
when = datetime.now() + timedelta(seconds=STATUS_UPDATE_FAILURE_DELAY_TIME)
self._unsub_refresh_state = async_track_point_in_time(self._hass, self._create_refresh_state_task, when)
return
if self._indego_client.state:
state = self._indego_client.state.state
if (500 <= state <= 799) or (state in (257, 260)):
try:
_LOGGER.debug("Refreshing operating data.")
await self._update_operating_data()
except Exception as exc:
_LOGGER.warning("Mower operating data update failed, reason: %s", exc)
if self._indego_client.state.error != self._latest_alert:
self._latest_alert = self._indego_client.state.error
try:
_LOGGER.debug("Refreshing alerts, to get new alert.")
await self._update_alerts()
except Exception as exc:
_LOGGER.warning("Mower alerts update failed, reason: %s", exc)
self._create_refresh_state_task()
def _create_refresh_state_task(self, event=None):
"""Create a task to refresh the mower state."""
self._refresh_state_task = self._hass.async_create_task(self.refresh_state())
def _cancel_delayed_refresh_state(self):
"""Cancel a delayed refresh state callback (if any exists)."""
if self._unsub_refresh_state is None:
return
self._unsub_refresh_state()
self._unsub_refresh_state = None
async def refresh_10m(self, _=None):
"""Refresh Indego sensors every 10m."""
_LOGGER.debug("Refreshing 10m.")
results = await asyncio.gather(
*[
self._update_generic_data(),
self._update_alerts(),
self._update_last_completed_mow(),
self._update_next_mow(),
],
return_exceptions=True,
)
next_refresh = 600
index = 0
for res in results:
if res and isinstance(res, BaseException):
try:
raise res
except Exception as e:
_LOGGER.warning("Uncaught error: %s on index: %s", e, index)
index += 1
self._refresh_10m_remover = async_call_later(
self._hass, next_refresh, self.refresh_10m
)
async def refresh_24h(self, _=None):
"""Refresh Indego sensors every 24h."""
_LOGGER.debug("Refreshing 24h.")
try:
await self._update_updates_available()
except Exception as e:
_LOGGER.info("Update updates available got an exception: %s", e)
self._refresh_24h_remover = async_call_later(self._hass, 86400, self.refresh_24h)
async def _update_operating_data(self):
await self._indego_client.update_operating_data()
# dependent state updates
_LOGGER.debug(f"Updating operating data")
if self._indego_client.operating_data:
self.entities[ENTITY_ONLINE].state = self._indego_client._online
self.entities[
ENTITY_BATTERY
].state = self._indego_client.operating_data.battery.percent_adjusted
# dependent attribute updates
self.entities[ENTITY_BATTERY].add_attribute(
{
"last_updated": homeassistant.util.dt.as_local(utcnow()).strftime(
"%Y-%m-%d %H:%M"
),
"voltage_V": self._indego_client.operating_data.battery.voltage,
"discharge_Ah": self._indego_client.operating_data.battery.discharge,
"cycles": self._indego_client.operating_data.battery.cycles,
f"battery_temp_{TEMP_CELSIUS}": self._indego_client.operating_data.battery.battery_temp,
f"ambient_temp_{TEMP_CELSIUS}": self._indego_client.operating_data.battery.ambient_temp,
}
)
else:
self.entities[ENTITY_ONLINE].state = self._indego_client._online
async def _update_state(self):
await self._indego_client.update_state(longpoll=True, longpoll_timeout=300)
# dependent state updates
if self._shutdown:
return
if not self._indego_client.state:
return # State update failed
self.entities[ENTITY_MOWER_STATE].state = self._indego_client.state_description
self.entities[
ENTITY_MOWER_STATE_DETAIL
].state = self._indego_client.state_description_detail
self.entities[ENTITY_LAWN_MOWED].state = self._indego_client.state.mowed
self.entities[ENTITY_RUNTIME].state = self._indego_client.state.runtime.total.cut
self.entities[ENTITY_BATTERY].charging = (
True if self._indego_client.state_description_detail == "Charging" else False
)
# dependent attribute updates
self.entities[ENTITY_MOWER_STATE].add_attribute(
{
"last_updated": homeassistant.util.dt.as_local(utcnow()).strftime(
"%Y-%m-%d %H:%M"
)
}
)
self.entities[ENTITY_MOWER_STATE_DETAIL].add_attribute(
{
"last_updated": homeassistant.util.dt.as_local(utcnow()).strftime(
"%Y-%m-%d %H:%M"
),
"state_number": self._indego_client.state.state,
"state_description": self._indego_client.state_description_detail,
}
)
self.entities[ENTITY_LAWN_MOWED].add_attribute(
{
"last_updated": homeassistant.util.dt.as_local(utcnow()).strftime(
"%Y-%m-%d %H:%M"
),
"last_session_operation_min": self._indego_client.state.runtime.session.operate,
"last_session_cut_min": self._indego_client.state.runtime.session.cut,
"last_session_charge_min": self._indego_client.state.runtime.session.charge,
}
)
self.entities[ENTITY_RUNTIME].add_attribute(
{
"total_operation_time_h": self._indego_client.state.runtime.total.operate,
"total_mowing_time_h": self._indego_client.state.runtime.total.cut,
"total_charging_time_h": self._indego_client.state.runtime.total.charge,
}
)
async def _update_generic_data(self):
await self._indego_client.update_generic_data()
# dependent state updates
if self._indego_client.generic_data:
if ENTITY_MOWING_MODE in self.entities:
self.entities[
ENTITY_MOWING_MODE
].state = self._indego_client.generic_data.mowing_mode_description
return self._indego_client.generic_data
async def _update_alerts(self):
await self._indego_client.update_alerts()
# dependent state updates
if self._indego_client.alerts:
self.entities[ENTITY_ALERT].state = self._indego_client.alerts_count > 0
self.entities[ENTITY_ALERT].add_attribute(
{"alerts_count": self._indego_client.alerts_count, }
)
else:
self.entities[ENTITY_ALERT].state = 0
j = len(self._indego_client.alerts)
# _LOGGER.info(f"Structuring ALERTS.{j}")
for i in range(j):
self.entities[ENTITY_ALERT].add_attribute(
{
self._indego_client.alerts[i].date.strftime("%Y-%m-%d %H:%M"): str(
self._indego_client.alerts[i].alert_description
),
}
)
async def _update_updates_available(self):
await self._indego_client.update_updates_available()
# dependent state updates
self.entities[ENTITY_UPDATE_AVAILABLE].state = self._indego_client.update_available
async def _update_last_completed_mow(self):
await self._indego_client.update_last_completed_mow()
if self._indego_client.last_completed_mow:
self.entities[
ENTITY_LAST_COMPLETED
].state = self._indego_client.last_completed_mow.isoformat()
self.entities[ENTITY_LAST_COMPLETED].add_attribute(
{
"last_completed_mow": self._indego_client.last_completed_mow.strftime(
"%Y-%m-%d %H:%M"
)
}
)
self.entities[ENTITY_LAWN_MOWED].add_attribute(
{
"last_completed_mow": self._indego_client.last_completed_mow.strftime(
"%Y-%m-%d %H:%M"
)
}
)
async def _update_next_mow(self):
await self._indego_client.update_next_mow()
if self._indego_client.next_mow:
self.entities[ENTITY_NEXT_MOW].state = self._indego_client.next_mow.isoformat()
self.entities[ENTITY_NEXT_MOW].add_attribute(
{"next_mow": self._indego_client.next_mow.strftime("%Y-%m-%d %H:%M")}
)
self.entities[ENTITY_LAWN_MOWED].add_attribute(
{"next_mow": self._indego_client.next_mow.strftime("%Y-%m-%d %H:%M")}
)
@property
def serial(self) -> str:
return self._serial