Skip to content

Commit

Permalink
Add debouncing and switch to per-device coordinators (#21)
Browse files Browse the repository at this point in the history
* Implement basic debouncer for fetching from REST API

* Move some hardcoded values to constants

* Make debouncing per-request

* Use multiple coordinators

* Implement review remarks from @joostlek

---------

Co-authored-by: Frederick Gnodtke <[email protected]>
  • Loading branch information
WebSpider and Prior99 authored Sep 21, 2024
1 parent 25f9186 commit 35f0da3
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 85 deletions.
23 changes: 18 additions & 5 deletions custom_components/myskoda/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.ssl import get_default_context
from myskoda import MySkoda

from .const import COORDINATOR, DOMAIN
from .const import COORDINATORS, DOMAIN
from .coordinator import MySkodaDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)
Expand All @@ -25,15 +28,25 @@
async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool:
"""Set up MySkoda integration from a config entry."""

coordinator = MySkodaDataUpdateCoordinator(hass, config)
myskoda = MySkoda(async_get_clientsession(hass))

if not await coordinator.async_login():
try:
await myskoda.connect(
config.data["email"], config.data["password"], get_default_context()
)
except Exception:
_LOGGER.error("Login with MySkoda failed.")
return False

await coordinator.async_config_entry_first_refresh()
coordinators: dict[str, MySkodaDataUpdateCoordinator] = {}
vehicles = await myskoda.list_vehicle_vins()
for vin in vehicles:
coordinator = MySkodaDataUpdateCoordinator(hass, config, myskoda, vin)
await coordinator.async_config_entry_first_refresh()
coordinators[vin] = coordinator

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config.entry_id] = {COORDINATOR: coordinator}
hass.data[DOMAIN][config.entry_id] = {COORDINATORS: coordinators}

await hass.config_entries.async_forward_entry_setups(config, PLATFORMS)

Expand Down
4 changes: 2 additions & 2 deletions custom_components/myskoda/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
add_supported_entities,
)

from .const import COORDINATOR, DOMAIN
from .const import COORDINATORS, DOMAIN


async def async_setup_entry(
Expand All @@ -44,7 +44,7 @@ async def async_setup_entry(
ChargerLocked,
SunroofOpen,
],
coordinator=hass.data[DOMAIN][config.entry_id][COORDINATOR],
coordinators=hass.data[DOMAIN][config.entry_id][COORDINATORS],
async_add_entities=async_add_entities,
)

Expand Down
4 changes: 2 additions & 2 deletions custom_components/myskoda/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from myskoda.models.air_conditioning import AirConditioning
from myskoda.models.info import CapabilityId

from .const import COORDINATOR, DOMAIN
from .const import COORDINATORS, DOMAIN
from .coordinator import MySkodaDataUpdateCoordinator
from .entity import MySkodaEntity
from .utils import InvalidCapabilityConfigurationError, add_supported_entities
Expand All @@ -33,7 +33,7 @@ async def async_setup_entry(
) -> None:
add_supported_entities(
available_entities=[MySkodaClimate],
coordinator=hass.data[DOMAIN][config.entry_id][COORDINATOR],
coordinators=hass.data[DOMAIN][config.entry_id][COORDINATORS],
async_add_entities=async_add_entities,
)

Expand Down
5 changes: 4 additions & 1 deletion custom_components/myskoda/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Constants for the MySkoda integration."""

DOMAIN = "myskoda"
COORDINATOR = "coordinator"
COORDINATORS = "coordinators"

FETCH_INTERVAL_IN_MINUTES = 30
API_COOLDOWN_IN_SECONDS = 30.0
173 changes: 111 additions & 62 deletions custom_components/myskoda/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,140 @@
from collections.abc import Coroutine
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Callable

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.ssl import get_default_context
from myskoda import MySkoda, Vehicle
from myskoda.event import Event, EventAccess, EventAirConditioning, ServiceEventTopic
from myskoda.event import (
Event,
EventAccess,
EventAirConditioning,
EventOperation,
ServiceEventTopic,
)
from myskoda.models.operation_request import OperationName, OperationStatus
from myskoda.models.user import User
from myskoda.mqtt import EventCharging, EventType

from .const import DOMAIN
from .const import DOMAIN, FETCH_INTERVAL_IN_MINUTES, API_COOLDOWN_IN_SECONDS

_LOGGER = logging.getLogger(__name__)

type RefreshFunction = Callable[[], Coroutine[None, None, None]]


class MySkodaDebouncer(Debouncer):
"""Class to rate limit calls to MySkoda REST APIs."""

def __init__(self, hass: HomeAssistant, func: RefreshFunction) -> None:
"""Initialize debounce."""
super().__init__(
hass,
_LOGGER,
cooldown=API_COOLDOWN_IN_SECONDS,
immediate=False,
function=func,
)


@dataclass
class State:
vehicles: dict[str, Vehicle]
vehicle: Vehicle
user: User

def __init__(self, vehicles: list[Vehicle], user: User) -> None:
self.vehicles = {}
for vehicle in vehicles:
self.vehicles[vehicle.info.vin] = vehicle
self.user = user


class MySkodaDataUpdateCoordinator(DataUpdateCoordinator[State]):
"""See `DataUpdateCoordinator`.
This class manages all data from the MySkoda API.
"""

myskoda: MySkoda
config: ConfigEntry
data: State

def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
def __init__(
self, hass: HomeAssistant, config: ConfigEntry, myskoda: MySkoda, vin: str
) -> None:
"""Create a new coordinator."""

super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30)
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=FETCH_INTERVAL_IN_MINUTES),
always_update=False,
)
self.myskoda = MySkoda(async_get_clientsession(hass))
self.hass = hass
self.vin = vin
self.myskoda = myskoda
self.config = config
self.update_driving_range = self._debounce(self._update_driving_range)
self.update_charging = self._debounce(self._update_charging)
self.update_air_conditioning = self._debounce(self._update_air_conditioning)
self.update_vehicle = self._debounce(self._update_vehicle)

async def async_login(self) -> bool:
"""Login to the MySkoda API. Will return `True` if successful."""

try:
await self.myskoda.connect(
self.config.data["email"],
self.config.data["password"],
get_default_context(),
)
self.myskoda.subscribe(self._on_mqtt_event)
return True
except Exception:
_LOGGER.error("Login with MySkoda failed.")
return False
myskoda.subscribe(self._on_mqtt_event)

async def _async_update_data(self) -> State:
vehicles = await self.myskoda.get_all_vehicles()
vehicle = await self.myskoda.get_vehicle(self.vin)
user = await self.myskoda.get_user()
return State(vehicles, user)
return State(vehicle, user)

async def _on_mqtt_event(self, event: Event) -> None:
if event.type != EventType.SERVICE_EVENT:
if event.vin != self.vin:
return
if event.topic == ServiceEventTopic.CHARGING:
await self._on_charging_event(event)
if event.topic == ServiceEventTopic.ACCESS:
await self._on_access_event(event)
if event.topic == ServiceEventTopic.AIR_CONDITIONING:
await self._on_air_conditioning_event(event)

if event.type == EventType.OPERATION:
await self._on_operation_event(event)
if event.type == EventType.SERVICE_EVENT:
if event.topic == ServiceEventTopic.CHARGING:
await self._on_charging_event(event)
if event.topic == ServiceEventTopic.ACCESS:
await self._on_access_event(event)
if event.topic == ServiceEventTopic.AIR_CONDITIONING:
await self._on_air_conditioning_event(event)

async def _on_operation_event(self, event: EventOperation) -> None:
if event.operation.status == OperationStatus.IN_PROGRESS:
return
if event.operation.operation in [
OperationName.STOP_AIR_CONDITIONING,
OperationName.START_AIR_CONDITIONING,
OperationName.SET_AIR_CONDITIONING_TARGET_TEMPERATURE,
OperationName.START_WINDOW_HEATING,
OperationName.STOP_WINDOW_HEATING,
]:
await self.update_air_conditioning()
if event.operation.operation in [
OperationName.UPDATE_CHARGE_LIMIT,
OperationName.UPDATE_CARE_MODE,
OperationName.UPDATE_CHARGING_CURRENT,
OperationName.START_CHARGING,
OperationName.STOP_CHARGING,
]:
await self.update_charging()

async def _on_charging_event(self, event: EventCharging):
vehicle = self.data.vehicles[event.vin]
vehicle = self.data.vehicle

data = event.event.data

if vehicle.charging is None or vehicle.charging.status is None:
await self.update_charging(event.vin)
await self.update_charging()
else:
status = vehicle.charging.status

status.battery.remaining_cruising_range_in_meters = data.charged_range
status.battery.state_of_charge_in_percent = data.soc
status.state = data.state
status.state = data.state
self.async_set_updated_data

if vehicle.driving_range is None:
await self.update_driving_range(event.vin)
await self.update_driving_range()
else:
vehicle.driving_range.primary_engine_range.current_so_c_in_percent = (
data.soc
Expand All @@ -101,39 +143,46 @@ async def _on_charging_event(self, event: EventCharging):
data.charged_range
)

self._set_updated_vehicle(vehicle)
self.set_updated_vehicle(vehicle)

async def _on_access_event(self, event: EventAccess):
await self.update_vehicle(event.vin)
await self.update_vehicle()

async def _on_air_conditioning_event(self, event: EventAirConditioning):
await self.update_air_conditioning(event.vin)
await self.update_air_conditioning()

def _unsub_refresh(self):
return

def _set_updated_vehicle(self, vehicle: Vehicle) -> None:
self.data.vehicles[vehicle.info.vin] = vehicle
def set_updated_vehicle(self, vehicle: Vehicle) -> None:
self.data.vehicle = vehicle
self.async_set_updated_data(self.data)

async def update_driving_range(self, vin: str) -> None:
driving_range = await self.myskoda.get_driving_range(vin)
vehicle = self.data.vehicles[vin]
async def _update_driving_range(self) -> None:
_LOGGER.debug("Updating driving range for %s", self.vin)
driving_range = await self.myskoda.get_driving_range(self.vin)
vehicle = self.data.vehicle
vehicle.driving_range = driving_range
self._set_updated_vehicle(vehicle)
self.set_updated_vehicle(vehicle)

async def update_charging(self, vin: str) -> None:
charging = await self.myskoda.get_charging(vin)
vehicle = self.data.vehicles[vin]
async def _update_charging(self) -> None:
_LOGGER.debug("Updating charging information for %s", self.vin)
charging = await self.myskoda.get_charging(self.vin)
vehicle = self.data.vehicle
vehicle.charging = charging
self._set_updated_vehicle(vehicle)
self.set_updated_vehicle(vehicle)

async def update_air_conditioning(self, vin: str) -> None:
air_conditioning = await self.myskoda.get_air_conditioning(vin)
vehicle = self.data.vehicles[vin]
async def _update_air_conditioning(self) -> None:
_LOGGER.debug("Updating air conditioning for %s", self.vin)
air_conditioning = await self.myskoda.get_air_conditioning(self.vin)
vehicle = self.data.vehicle
vehicle.air_conditioning = air_conditioning
self._set_updated_vehicle(vehicle)
self.set_updated_vehicle(vehicle)

async def _update_vehicle(self) -> None:
_LOGGER.debug("Updating full vehicle for %s", self.vin)
vehicle = await self.myskoda.get_vehicle(self.vin)
self.set_updated_vehicle(vehicle)

async def update_vehicle(self, vin: str) -> None:
vehicle = await self.myskoda.get_vehicle(vin)
self._set_updated_vehicle(vehicle)
def _debounce(self, func: RefreshFunction) -> RefreshFunction:
return MySkodaDebouncer(self.hass, func).async_call
6 changes: 3 additions & 3 deletions custom_components/myskoda/device_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from myskoda.models.info import CapabilityId
from myskoda.models.position import Position, PositionType, Positions

from .const import COORDINATOR, DOMAIN
from .const import COORDINATORS, DOMAIN
from .coordinator import MySkodaDataUpdateCoordinator
from .entity import MySkodaEntity
from .utils import InvalidCapabilityConfigurationError, add_supported_entities
Expand All @@ -25,7 +25,7 @@ async def async_setup_entry(
"""Set up the sensor platform."""
add_supported_entities(
available_entities=[DeviceTracker],
coordinator=hass.data[DOMAIN][config.entry_id][COORDINATOR],
coordinators=hass.data[DOMAIN][config.entry_id][COORDINATORS],
async_add_entities=async_add_entities,
)

Expand All @@ -34,7 +34,7 @@ class DeviceTracker(MySkodaEntity, TrackerEntity):
"""GPS device tracker for MySkoda."""

def __init__(self, coordinator: MySkodaDataUpdateCoordinator, vin: str) -> None: # noqa: D107
title = coordinator.data.vehicles[vin].info.specification.title
title = coordinator.data.vehicle.info.specification.title
self.entity_description = EntityDescription(
name=title,
key=f"{vin}_device_tracker",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/myskoda/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(

@property
def vehicle(self) -> Vehicle:
return self.coordinator.data.vehicles[self.vin]
return self.coordinator.data.vehicle

@property
def device_info(self) -> DeviceInfo: # noqa: D102
Expand Down
4 changes: 2 additions & 2 deletions custom_components/myskoda/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from myskoda.models.charging import Settings
from myskoda.models.info import CapabilityId

from .const import COORDINATOR, DOMAIN
from .const import COORDINATORS, DOMAIN
from .entity import MySkodaEntity
from .utils import InvalidCapabilityConfigurationError, add_supported_entities

Expand All @@ -31,7 +31,7 @@ async def async_setup_entry(
"""Set up the sensor platform."""
add_supported_entities(
available_entities=[ChargeLimit],
coordinator=hass.data[DOMAIN][config.entry_id][COORDINATOR],
coordinators=hass.data[DOMAIN][config.entry_id][COORDINATORS],
async_add_entities=async_add_entities,
)

Expand Down
Loading

0 comments on commit 35f0da3

Please sign in to comment.